Merge remote-tracking branch 'origin/main' into feature/oeffnungszeiten

Zusammenführung von TP2-Abschluss und Webshop (origin/main cab9c33) mit Öffnungszeiten live. Beide
Funktionen bleiben vollständig: jeder Konflikt ist als Vereinigung gelöst, Reihenfolge der Funktionsschlüssel
überall news, termine, shop, zeiten.

Konfliktdateien (28): public/app.js, dashboard.html, funktionen.js, widerrufen.html; scripts/stripe-products.mjs;
server/config.js, index.js, middleware/kundenansichtGuard.js, routes/abo.js, routes/admin/funktionenAdmin.js,
routes/funktionen.js, services/customerAdmin.js, customerViewLog.js, featureRegistry.js, retention.js,
siteFeatures.js, stripeBilling.js; test/customerViewLog, featureFreischaltung, featureRegistry, funktionenRoutes,
funktionenTermine, kundenansichtGuard, mailTemplates, publicForms, siteFeatures, stripeProductsScript (je
.test.js) und test/support/schema.js.

Entscheidungen: Mailvorlagen G01–G15, K01–K30, T01–T21 lückenlos (K24–K26 Webshop, K27–K30 Öffnungszeiten);
Stripe-Katalog mit shopSetup und zeitenSetup; Widerruf kennt shop und zeiten; ABO_FUNKTIONEN bleibt analyse
und termine (kein zeiten); activate() schreibt die Zeiten-Felder nur über extra; kein Halter von
feature-activate:<id> ruft disable() auf; die Aufbewahrungs-Löschung löscht Zeiten-Daten vor dem Abschalten und
hält den Kunden bei einem Fehler (held); die Admin-Antwort trägt ersatzTicket und warnung; Hash-Routen webshop
und zeiten, Ansichten view-webshop und view-zeiten; assertOwnProject behält die Meldung von main.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-06 06:12:23 +00:00
commit e87140e5ba
245 files changed
+38799 -1050

No files matched your search

+297
View File
@@ -0,0 +1,297 @@
/* HTML-Bausteine der Analyse-Uebersicht ("Cockpit"). Reine String-Builder ohne DOM und ohne Netz; analyse.js setzt sie ein, analyse.css stylt sie.
Jeder Wert aus Daten (Pfade, Hosts, Projektnamen, Tage, Links) laeuft durch esc(). Texte: Spec 2.1-2.4.
Vertrag fuer analyse.js und analyse.css (Praefix an-, Zustaende als Klasse "on" oder Attribut):
Kopf .an-top > .an-site[data-state=on|warn|off] (.an-status-dot, .an-site-name | select#an-project.an-pick, .an-status "Analyse aktiv · Heute bisher: N Aufrufe · Stand: HH:MM Uhr") + .an-seg[role=group] button[data-range=7|30|90][aria-pressed] (.on)
Reiter .an-tabs[role=tablist] > button.an-tabbtn[role=tab][data-tab=uebersicht|heatmap|einstellungen][aria-selected] (.on), aria-controls="an-stage"
Wurzel .an-ov (Raster, Abstand 14 px) enthaelt: .an-sum, .an-kpis, .an-row2, 2x .an-pair, .an-credit
Wichtigste .an-sum > h3.an-eyebrow, p.an-lead (darin <b>), ul.an-points > li > span.an-point-ico (svg.an-ico) + span; Leerzustand: .an-sum.an-sum-empty
Kacheln .an-kpis > 4x .an-card.an-kpi[data-kpi=pv|visitors|contacts|time] > span.an-k (span.an-star), b.an-v, small.an-sub, .an-cmp (span.an-delta.up|down|flat[role=img][aria-label], span.an-vs), svg.an-spark (path.an-spark-area, path.an-spark-line)
Diagramm .an-card.an-chartbox > .an-head (h3, .an-legend > span.an-leg > i.an-leg-line[.an-leg-prev]) + .an-chartwrap > svg.an-chart (line.an-grid, text.an-axis, path.an-area, path.an-prev nur mit Vergleich, path.an-line,
circle.an-dot[data-i][data-day][data-pv][data-prev] fuer jeden Tag, per CSS erst bei Fokus sichtbar) + div.an-tip[hidden] (Blase; Text aus chartTipText)
Kontakte .an-card.an-contacts > .an-head (h3, b.an-sum-n) + ul.an-crows > li.an-crow[data-kind=tel|mail|wa|out] (span.an-cico, span.an-cname, b, span.an-delta) + p.an-quote; Leertext: p.an-empty
Ranglisten section.an-card.an-rank[data-rank=seen|who][data-rank-active=<Reiter>] > h3, .an-ltabs[role=group] > button.an-ltab[data-rank-tab][aria-pressed] (.on), ul.an-lrows > li.an-lr
(span.an-lab > i.an-fill[style=width:N%] + span.an-name[title], b, em), button.an-more[data-rank-more] ("Alle anzeigen", nur bei mehr als 6 Zeilen und nicht aufgeklappt)
Datenbasis .an-card.an-basis > svg.an-ring (circle.an-ring-bg, circle.an-ring-fg, text.an-ring-n) + p.an-basis-text > span.an-basis-main
Heatmap .an-card.an-teaser > .an-teaser-row > .an-mini-heat + p + button.an-btn[data-tab=heatmap] (data-tab auch auf dem Reiterknopf: Handler auf .an-tabs bzw. .an-teaser einschraenken)
Einstell. .an-ov.an-settings > .an-card.an-status-card (dl.an-dl, span.an-swatch) + .an-card.an-docs (ul > li > a) + section.an-card#an-off (leer, fuellt renderAnalyseOff)
Laden .an-ov.an-skeleton[aria-busy] mit .an-sk-Platzhaltern (.an-sk > i); .an-sr = nur fuer Screenreader sichtbar (muss in analyse.css stehen)
Fehler section.an-card.an-error[role=alert] > p + button.an-btn#an-retry */
import { esc } from './fn-util.js'
import { fmtNum, fmtDuration, periodLabel, shortDay, dayLabel, compareFrom, delta, contactRate, refName, summarize, chartModel, barRows, countryName, flagEmoji, DEVICE_LABELS, bannerColor } from './analyse-view.js'
const RANGES = [7, 30, 90]
const isNum = (v) => typeof v === 'number' && Number.isFinite(v)
const num = (v) => (Number.isFinite(Number(v)) ? Number(v) : 0)
const round = (n) => Math.round(n * 10) / 10
const list = (v) => (Array.isArray(v) ? v : [])
// Symbole wie ICONS in funktionen.js: 24er Raster, nur Striche
const PHONE = '<path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72c.13.96.36 1.9.7 2.81a2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45c.91.34 1.85.57 2.81.7A2 2 0 0 1 22 16.92z"/>'
const ICONS = {
contact: PHONE,
tel: PHONE,
mail: '<rect x="2" y="4" width="20" height="16" rx="2"/><path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"/>',
wa: '<path d="M7.9 20A9 9 0 1 0 4 16.1L2 22z"/>',
out: '<path d="M7 7h10v10M7 17 17 7"/>',
day: '<rect x="3" y="5" width="18" height="16" rx="3"/><path d="M8 3v4M16 3v4M3 10h18"/>',
source: '<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>',
device: '<rect x="6" y="2" width="12" height="20" rx="3"/><path d="M11 18h2"/>',
page: '<path d="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z"/><path d="M14 3v5h5M9 13h6M9 17h6"/>',
}
const ico = (name) => `<svg class="an-ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${ICONS[name] || ''}</svg>`
// Veraenderungs-Chip aus delta(); ohne Wert nichts
const chip = (d) => (d ? `<span class="an-delta ${d.dir}" role="img" aria-label="${esc(d.aria)}">${esc(d.text)}</span>` : '')
const rateDe = (n) => n.toLocaleString('de-DE', { minimumFractionDigits: 1, maximumFractionDigits: 1 })
/* --- Kopfleiste und Reiter, Spec 2.1 --- */
const STATE_TEXT = { disabled: 'Analyse ist abgeschaltet', unknown: 'Status gerade nicht abrufbar' }
// Statuszeile: aktiv (mit "Heute bisher" aus stats.today und Stand), Dev-Modus (gelb) oder, bei Websites ohne Messung, der tatsaechliche Zustand
function statusOf(project, devMode, stamp, today) {
if (devMode) return { s: 'warn', text: 'Im Dev-Modus wird nicht gemessen.' }
const a = project?.analyse
const state = typeof a?.state === 'string' ? a.state : 'active'
if (state === 'active') {
const pv = isNum(today?.pv) ? today.pv : null // alter Collector ohne today: kein "Heute bisher"
// jeder Teil bricht nur als Ganzes in die naechste Zeile (kein "Stand:" allein am Zeilenende)
const parts = ['Analyse aktiv', pv === null ? '' : `Heute bisher: ${fmtNum(pv)} ${pv === 1 ? 'Aufruf' : 'Aufrufe'}`, stamp ? `Stand: ${stamp} Uhr` : '']
return { s: 'on', text: parts.filter(Boolean).map((t) => `<span class="an-st">${t}</span>`).join(' · ') }
}
return { s: 'off', text: Object.hasOwn(STATE_TEXT, state) ? STATE_TEXT[state] : a.eligible === false ? 'Analyse nicht verfügbar' : 'Analyse ist noch ausgeschaltet' }
}
/** Kopfleiste. usable: Websites mit Analyse (Auswahlfeld erst ab 2), stamp: Uhrzeit "19:42" des Ladens, today: stats.today (heute bisher). */
export function headerHtml({ project, usable, range, showRange, devMode, stamp, today } = {}) {
const sites = list(usable)
const st = statusOf(project, devMode, stamp ? esc(stamp) : '', today)
const current = RANGES.includes(Number(range)) ? Number(range) : 30
const name = sites.length > 1
? `<select id="an-project" class="an-pick" aria-label="Website wählen">${sites.map((p) => `<option value="${esc(p.projectId)}"${p.projectId === project?.projectId ? ' selected' : ''}>${esc(p.projectName)}</option>`).join('')}</select>`
: `<b class="an-site-name">${esc(project?.projectName)}</b>`
const seg = showRange
? `<div class="an-seg" role="group" aria-label="Zeitraum">${RANGES.map((r) => `<button type="button"${r === current ? ' class="on"' : ''} data-range="${r}" aria-pressed="${r === current}">${r} Tage</button>`).join('')}</div>`
: ''
return `<div class="an-top"><div class="an-site" data-state="${st.s}"><span class="an-status-dot" aria-hidden="true"></span><div>${name}<small class="an-status">${st.text}</small></div></div>${seg}</div>`
}
const TABS = [['uebersicht', 'Übersicht'], ['heatmap', 'Heatmap'], ['einstellungen', 'Einstellungen']]
export function tabsHtml(tab) {
const active = TABS.some(([k]) => k === tab) ? tab : 'uebersicht'
return `<div class="an-tabs" role="tablist" aria-label="Bereiche der Analyse">${TABS.map(([k, label]) => `<button type="button" role="tab" class="an-tabbtn${k === active ? ' on' : ''}" id="an-tab-${k}" data-tab="${k}" aria-selected="${k === active}" aria-controls="an-stage">${label}</button>`).join('')}</div>`
}
/* --- Das Wichtigste und Kennzahlen, Spec 2.1, 2.3, 2.4 --- */
export function summaryHtml(stats) {
const s = summarize(stats)
const period = periodLabel(stats?.from, stats?.to)
const head = `<h3 class="an-eyebrow">Das Wichtigste${period ? ` · ${esc(period)}` : ''}</h3>`
if (!s) return `<section class="an-sum an-sum-empty">${head}<p class="an-lead">Sobald ein paar mehr Besuche da sind, steht hier das Wichtigste über deine Website in zwei, drei Sätzen.</p></section>`
// lead und items[].html liefert summarize bereits maskiert
const points = s.items.length ? `<ul class="an-points">${s.items.map((i) => `<li><span class="an-point-ico">${ico(i.icon)}</span><span>${i.html}</span></li>`).join('')}</ul>` : ''
return `<section class="an-sum">${head}<p class="an-lead">${s.lead}</p>${points}</section>`
}
/** Mini-Verlauf (Flaeche + Linie, ohne Achsen) aus Zahlen; null/undefined ist eine Luecke (Tag ohne Messung): die Linie bricht dort ab, es wird nie als 0 gezeichnet. Unter 2 Werten nichts. */
export function sparkHtml(values) {
const v = list(values).map((x) => (x === null || x === undefined ? null : num(x)))
if (v.length < 2 || v.filter((x) => x !== null).length < 2) return ''
const max = Math.max(1, ...v.filter((x) => x !== null)) * 1.1
const x = (i) => round((200 * i) / (v.length - 1))
const pt = (y, i) => `${x(i)} ${round(36 - (32 * y) / max)}`
// zusammenhaengende Abschnitte ohne Luecke: [[wert, index], ...]
const runs = []
v.forEach((y, i) => {
if (y === null) runs.push(null)
else if (runs.length && runs[runs.length - 1]) runs[runs.length - 1].push([y, i])
else runs.push([[y, i]])
})
const parts = runs.filter(Boolean)
const seg = (r) => r.map(([y, i], k) => `${k ? 'L' : 'M'}${pt(y, i)}`).join(' ')
// ein einzelner Tag zwischen Luecken wird ein Punkt (Strich der Laenge 0, runde Linienenden)
const line = parts.map((r) => (r.length === 1 ? `${seg(r)} L${pt(...r[0])}` : seg(r))).join(' ')
const area = parts.filter((r) => r.length > 1).map((r) => `${seg(r)} L${x(r[r.length - 1][1])} 38 L${x(r[0][1])} 38 Z`).join(' ')
return `<svg class="an-spark" viewBox="0 0 200 38" preserveAspectRatio="none" aria-hidden="true" focusable="false"><path class="an-spark-area" d="${area}"/><path class="an-spark-line" d="${line}" fill="none" vector-effect="non-scaling-stroke"/></svg>`
}
// Tageswerte einer Reihe: fehlende Werte 0; Reihe ohne das Feld (alter Collector) => null.
// gaps: Tage ohne Wert bleiben null (Luecke im Verlauf), z.B. die Zeit pro Seite an Tagen ohne Zustimmung
const trend = (series, key, gaps = false) => {
const rows = list(series)
return rows.some((d) => Object.hasOwn(d ?? {}, key)) ? rows.map((d) => (gaps && !isNum(d?.[key]) ? null : num(d?.[key]))) : null
}
export function kpisHtml(stats) {
const t = stats?.totals || {}
const cmp = compareFrom(stats)
const p = cmp.ok ? stats.previous.totals || {} : null
const tiles = [
{ key: 'pv', label: 'Seitenaufrufe', value: fmtNum(t.pv), d: p && delta(t.pv, p.pv), vs: p && fmtNum(p.pv) },
{ key: 'visitors', label: 'Besucher', star: true, value: fmtNum(t.visitors), d: p && delta(t.visitors, p.visitors), vs: p && fmtNum(p.visitors) },
{ key: 'contacts', label: 'Kontakt-Aktionen', star: true, value: fmtNum(t.contacts), d: p && delta(t.contacts, p.contacts), vs: p && fmtNum(p.contacts) },
{ key: 'time', label: 'Ø Zeit pro Seite', star: true, value: fmtDuration(t.avgSec), sub: isNum(t.avgScroll) ? `Ø ${fmtNum(t.avgScroll)} % gescrollt` : '', d: p && delta(t.avgSec, p.avgSec, { kind: 'sec' }), vs: p && fmtDuration(p.avgSec) },
]
const field = { pv: 'pv', visitors: 'visitors', contacts: 'contacts', time: 'avgSec' }
const tile = (k) => {
const foot = k.d ? `${chip(k.d)}<span class="an-vs">vs. ${esc(k.vs)}</span>` : !cmp.ok && cmp.from ? `<span class="an-vs">Vergleich ab ${esc(cmp.from)}</span>` : ''
return `<div class="an-card an-kpi" data-kpi="${k.key}"><span class="an-k">${k.label}${k.star ? '<span class="an-star">*</span>' : ''}</span><b class="an-v">${esc(k.value)}</b>${k.sub ? `<small class="an-sub">${esc(k.sub)}</small>` : ''}<div class="an-cmp">${foot}</div>${sparkHtml(trend(stats?.series, field[k.key], k.key === 'time'))}</div>`
}
return `<div class="an-kpis">${tiles.map(tile).join('')}</div>`
}
/* --- Diagramm und Kontakt-Aktionen, Spec 2.1 --- */
/** Text der Blase ueber einem Tag: "Sa., 19.09.: 371 Aufrufe (davor 210)"; prev leer/null => ohne Klammer. */
export function chartTipText(day, pv, prev) {
const n = num(pv)
const before = prev === null || prev === undefined || prev === '' ? '' : ` (davor ${fmtNum(prev)})`
return `${dayLabel(day)}: ${fmtNum(n)} ${n === 1 ? 'Aufruf' : 'Aufrufe'}${before}`
}
export function chartHtml(stats) {
const series = list(stats?.series)
const cmp = compareFrom(stats)
const head = (legend) => `<div class="an-head"><h3>Seitenaufrufe pro Tag</h3>${legend}</div>`
if (!series.length) return `<section class="an-card an-chartbox">${head('')}<p class="an-empty">Noch keine Daten.</p></section>`
// Schrift der Achsen in viewBox-Einheiten: bis ca. 24 auf dem schmalsten Handy (analyse.css). Der linke Rand haelt die breiteste
// y-Beschriftung (Ziffern ca. 0,6 x 24 breit), die x-Beschriftungen halten 100 Einheiten Abstand (eine Datumsbeschriftung ist bei Schrift 28 ca. 92 breit)
const opts = { height: 300, maxLabels: 5, labelGap: 100, compare: cmp.ok ? stats.previous.series : undefined }
const padL = Math.max(40, Math.round(6 + String(chartModel(series, opts).top).length * 14.4))
const m = chartModel(series, { ...opts, pad: { l: padL, r: 24, t: 12, b: 40 } })
const hasPrev = Boolean(m.prevLine)
const days = Number(stats?.range) || series.length
const total = m.points.reduce((sum, p) => sum + p.pv, 0)
const title = `Seitenaufrufe pro Tag vom ${shortDay(m.points[0].day)} bis ${shortDay(m.points[m.points.length - 1].day)}, insgesamt ${fmtNum(total)}`
const legend = `<div class="an-legend"><span class="an-leg"><i class="an-leg-line"></i>${days} Tage</span>${hasPrev ? '<span class="an-leg"><i class="an-leg-line an-leg-prev"></i>Zeitraum davor</span>' : ''}</div>`
const dots = m.points.map((p, i) => `<circle class="an-dot" data-i="${i}" cx="${p.x}" cy="${p.y}" r="3" data-day="${esc(p.day)}" data-pv="${p.pv}"${p.prev === null ? '' : ` data-prev="${p.prev}"`}/>`).join('')
return `<section class="an-card an-chartbox">${head(legend)}<div class="an-chartwrap"><svg class="an-chart" viewBox="0 0 ${m.width} ${m.height}" role="img" aria-labelledby="an-chart-title"><title id="an-chart-title">${esc(title)}</title>`
+ m.yTicks.map((k) => `<line class="an-grid" x1="${m.pad.l}" x2="${m.width - m.pad.r}" y1="${k.y}" y2="${k.y}"/><text class="an-axis" x="${m.pad.l - 6}" y="${k.y}" dy="0.35em" text-anchor="end">${esc(k.label)}</text>`).join('')
+ m.xLabels.map((l) => `<text class="an-axis" x="${l.x}" y="${m.height - 9}" text-anchor="middle">${esc(l.label)}</text>`).join('')
+ `<path class="an-area" d="${m.area}"/>${hasPrev ? `<path class="an-prev" d="${m.prevLine}"/>` : ''}<path class="an-line" d="${m.line}"/>${dots}</svg><div class="an-tip" hidden></div></div></section>`
}
const CONTACT_KINDS = [['tel', 'Anrufe'], ['mail', 'E-Mails'], ['wa', 'WhatsApp'], ['out', 'Links nach außen']]
export function contactsHtml(stats) {
const t = stats?.totals || {}
const total = num(t.contacts)
const cmp = compareFrom(stats)
const before = cmp.ok ? stats.previous : null
const head = `<div class="an-head"><h3>Kontakt-Aktionen</h3><b class="an-sum-n">${fmtNum(total)}</b></div>`
if (total < 1) return `<section class="an-card an-contacts">${head}<p class="an-empty">Noch keine Kontakt-Aktionen. Sie zählen, wenn jemand auf deiner Website auf Telefonnummer, E-Mail-Adresse oder WhatsApp tippt.</p></section>`
const countOf = (rows, key) => num(list(rows).find((c) => c?.key === key)?.count)
const rows = CONTACT_KINDS.map(([key, label]) => {
const cur = countOf(stats.contacts, key)
const was = before ? countOf(before.contacts, key) : 0
if (!cur && !was) return ''
return `<li class="an-crow" data-kind="${key}"><span class="an-cico">${ico(key)}</span><span class="an-cname">${label}</span><b>${fmtNum(cur)}</b>${chip(before && delta(cur, was))}</li>`
}).join('')
// Kontakt-Aktionen sind Klicks, keine Personen: "pro 100 Besucher"; ab 30 Besuchern
const rate = contactRate(total, num(t.visitors))
const wasRate = before ? contactRate(num(before.totals?.contacts), num(before.totals?.visitors)) : null
const quote = rate === null ? '' : `<p class="an-quote">Auf 100 Besucher kommen ${rateDe(rate)} Kontakt-Aktionen${wasRate === null ? '' : ` (davor ${rateDe(wasRate)})`}.</p>`
return `<section class="an-card an-contacts">${head}${rows ? `<ul class="an-crows">${rows}</ul>` : ''}${quote}</section>`
}
/* --- Ranglisten, Spec 2.1 --- */
const CARDS = {
seen: { title: 'Was angesehen wird', tabs: [['pages', 'Seiten'], ['refs', 'Herkunft']] },
who: { title: 'Wer zu Besuch ist', tabs: [['devices', 'Geräte'], ['countries', 'Länder']] },
}
const NAMERS = {
pages: (key) => (key === '' || key === '/' ? 'Startseite' : key),
refs: refName,
devices: (key) => (Object.hasOwn(DEVICE_LABELS, key) ? DEVICE_LABELS[key] : 'Unbekannt'),
countries: countryName,
}
const SHORT = 6
const LONG = 20
/** Rangliste "seen" (Seiten | Herkunft) oder "who" (Geraete | Laender); tab waehlt den Reiter, expanded zeigt bis 20 statt 6 Zeilen. */
export function rankCardHtml(card, stats, { tab, expanded } = {}) {
if (!Object.hasOwn(CARDS, card)) return ''
const def = CARDS[card]
const active = def.tabs.some(([k]) => k === tab) ? tab : def.tabs[0][0]
const items = list(stats?.[active])
const listSum = items.reduce((a, r) => a + num(r?.count), 0)
// Anteil an allen Seitenaufrufen (die Liste zeigt hoechstens 20 Eintraege); Herkunft kennt nur die Summe der Liste (Besucher ohne Zustimmung haben keine)
const sum = active === 'refs' ? listSum : Math.max(listSum, num(stats?.totals?.pv))
const share = (c) => {
const pct = sum > 0 ? (c / sum) * 100 : 0
return pct > 0 && pct < 0.5 ? '&lt; 1 %' : `${Math.round(pct)} %`
}
const all = barRows(items, NAMERS[active])
const row = (r) => {
const flag = active === 'countries' && flagEmoji(r.key) ? `<span class="an-flag" aria-hidden="true">${flagEmoji(r.key)}</span> ` : ''
return `<li class="an-lr"><span class="an-lab"><i class="an-fill" style="width:${r.pct}%" aria-hidden="true"></i><span class="an-name" title="${esc(r.label)}">${flag}${esc(r.label)}</span></span><b>${fmtNum(r.count)}</b><em>${share(r.count)}</em></li>`
}
const tabs = def.tabs.map(([k, label]) => `<button type="button" class="an-ltab${k === active ? ' on' : ''}" data-rank-tab="${k}" aria-pressed="${k === active}">${label}</button>`).join('')
const rows = all.length ? `<ul class="an-lrows">${all.slice(0, expanded ? LONG : SHORT).map(row).join('')}</ul>` : '<p class="an-empty">Noch keine Daten.</p>'
const more = all.length > SHORT && !expanded ? '<button type="button" class="an-more" data-rank-more>Alle anzeigen</button>' : ''
return `<section class="an-card an-rank" data-rank="${card}" data-rank-active="${active}"><h3>${def.title}</h3><div class="an-ltabs" role="group" aria-label="Ansicht wählen">${tabs}</div>${rows}${more}</section>`
}
/* --- Datenbasis, Heatmap-Karte, Laden, Fehler --- */
export function basisHtml(stats) {
const b = stats?.banner || {}
const decided = num(b.accept) + num(b.reject)
const rate = decided > 0 ? Math.min(1, Math.max(0, isNum(stats?.totals?.consentRate) ? stats.totals.consentRate : num(b.accept) / decided)) : 0
const pct = Math.round(rate * 100)
const main = decided > 0
? `${fmtNum(pct)} % haben dem Banner zugestimmt (${fmtNum(b.accept)} von ${fmtNum(decided)} Entscheidung${decided === 1 ? '' : 'en'}).`
: 'Noch hat niemand über das Banner entschieden.'
const full = 2 * Math.PI * 30
const ring = `<svg class="an-ring" viewBox="0 0 74 74" aria-hidden="true"><circle class="an-ring-bg" cx="37" cy="37" r="30" fill="none" stroke-width="9"/>${rate > 0 ? `<circle class="an-ring-fg" cx="37" cy="37" r="30" fill="none" stroke-width="9" stroke-linecap="round" stroke-dasharray="${round(rate * full)} ${round(full)}" transform="rotate(-90 37 37)"/>` : ''}<text class="an-ring-n" x="37" y="42" text-anchor="middle">${decided > 0 ? `${fmtNum(pct)} %` : '–'}</text></svg>`
return `<section class="an-card an-basis"><h3>Datenbasis</h3><div class="an-basis-row">${ring}<p class="an-basis-text"><span class="an-basis-main">${main}</span> Seitenaufrufe zählen wir vollständig; Besucher, Kontakt-Aktionen und Zeit pro Seite (*) nur bei Zustimmung.</p></div></section>`
}
export function heatTeaserHtml() {
return '<section class="an-card an-teaser"><h3>Heatmap</h3><div class="an-teaser-row"><div class="an-mini-heat" aria-hidden="true"></div><p>Sieh auf deiner echten Website, wo geklickt wird – am Computer, Tablet und Handy.</p><button type="button" class="an-btn" data-tab="heatmap">Öffnen</button></div></section>'
}
/** Platzhalter in der Form der echten Kacheln, solange die Auswertung laedt. */
export function skeletonHtml() {
const card = (cls) => `<div class="an-card ${cls} an-sk" aria-hidden="true"><i></i><i></i><i></i></div>`
return `<div class="an-ov an-skeleton" aria-busy="true"><span class="an-sr" role="status">Auswertung wird geladen</span><div class="an-sum an-sk" aria-hidden="true"><i></i><i></i></div><div class="an-kpis">${card('an-kpi').repeat(4)}</div><div class="an-row2">${card('an-chartbox')}${card('an-contacts')}</div><div class="an-pair">${card('an-rank')}${card('an-rank')}</div></div>`
}
export function errorHtml(message) {
return `<section class="an-card an-error" role="alert"><p>${esc(message)}</p><button type="button" class="an-btn" id="an-retry">Neu laden</button></section>`
}
/* --- Reiter Einstellungen, Spec 2.2 --- */
const BERLIN_DATE = new Intl.DateTimeFormat('de-DE', { timeZone: 'Europe/Berlin', day: '2-digit', month: '2-digit', year: 'numeric' })
// Links nur auf eigene Pfade oder http(s)
const hrefOk = (url) => typeof url === 'string' && /^(\/(?!\/)|https?:\/\/)/i.test(url)
export function settingsHtml(project) {
const a = project?.analyse || {}
const color = bannerColor(a.accentColor) // wie auf der Website: zu helle Farbe => #111111
const since = a.activatedAt ? new Date(a.activatedAt) : null
const status = `Analyse aktiv${since && !Number.isNaN(since.getTime()) ? ` seit ${BERLIN_DATE.format(since)}` : ''}`
const rows = [
['Status', esc(status)],
['Farbe des Banners', `<span class="an-swatch" style="background:${color}" aria-hidden="true"></span> ${color}`],
a.avvVersion ? ['Vereinbarung', `Fassung ${esc(a.avvVersion)}`] : null,
].filter(Boolean)
const links = [[a.legal?.avv, 'Auftragsverarbeitungsvertrag Website-Analyse'], [a.legal?.lb, 'Leistungsbeschreibung Website-Analyse']]
.filter(([url]) => hrefOk(url)).map(([url, label]) => `<li><a href="${esc(url)}" target="_blank" rel="noopener">${label}</a></li>`)
return `<div class="an-ov an-settings"><section class="an-card an-status-card"><h3>Status</h3><dl class="an-dl">${rows.map(([k, v]) => `<div><dt>${k}</dt><dd>${v}</dd></div>`).join('')}</dl></section>`
+ `${links.length ? `<section class="an-card an-docs"><h3>Unterlagen</h3><ul>${links.join('')}</ul></section>` : ''}<section class="an-card" id="an-off"></section></div>`
}
/* --- Zusammenbau der Uebersicht --- */
export function overviewHtml(stats) {
return `<div class="an-ov">${summaryHtml(stats)}${kpisHtml(stats)}<div class="an-row2">${chartHtml(stats)}${contactsHtml(stats)}</div>`
+ `<div class="an-pair">${rankCardHtml('seen', stats)}${rankCardHtml('who', stats)}</div><div class="an-pair">${basisHtml(stats)}${heatTeaserHtml()}</div>`
+ '<p class="an-credit">Länderdaten: IP-Geolocation by <a href="https://db-ip.com" target="_blank" rel="noopener">DB-IP</a></p></div>'
}
+268 -7
View File
@@ -1,4 +1,6 @@
/* Reine Hilfsfunktionen der Analyse-Ansicht (funktionen.js). Ohne DOM, damit Node-Tests sie direkt importieren koennen. */
/* Reine Hilfsfunktionen und gemeinsamer Zustand der Analyse-Ansicht (analyse.js, Kachel in funktionen.js). Ohne DOM, damit Node-Tests sie direkt importieren koennen. */
import { esc } from './fn-util.js'
// Gleiche Regel wie analytics/client/core.js (contrastOk): weisser Text auf der Farbe braucht >= 4,5:1
export function contrastOk(hex) {
@@ -53,22 +55,33 @@ export function shortDay(day) {
return m ? `${m[2]}.${m[1]}.` : String(day || '')
}
/** Koordinaten fuer das Liniendiagramm. series: [{day, pv}] => Punkte, Achsenbeschriftung, Pfad. */
export function chartModel(series, { width = 640, height = 240, pad = { l: 40, r: 12, t: 12, b: 28 }, maxLabels = 6 } = {}) {
/** Koordinaten fuer das Liniendiagramm. series: [{day, pv}] => Punkte, Achsenbeschriftung, Pfad. Mit compare ([{pv}], Zeitraum davor) gemeinsame Skala und gestrichelte Vergleichslinie (nach Tagesindex ausgerichtet). labelGap: Mindestabstand (Einheiten) zwischen der letzten und der vorletzten Beschriftung der x-Achse. */
export function chartModel(series, { width = 640, height = 240, pad = { l: 40, r: 12, t: 12, b: 28 }, maxLabels = 6, labelGap = 36, compare } = {}) {
const data = Array.isArray(series) ? series : []
const before = Array.isArray(compare) ? compare : []
const prevOf = (i) => {
const v = before[i]?.pv
return v == null || !Number.isFinite(Number(v)) ? null : Number(v)
}
const innerW = width - pad.l - pad.r
const innerH = height - pad.t - pad.b
const top = niceMax(Math.max(0, ...data.map((d) => Number(d.pv) || 0)))
const top = niceMax(Math.max(0, ...data.map((d) => Number(d.pv) || 0), ...data.map((_, i) => prevOf(i) ?? 0)))
const x = (i) => pad.l + (data.length > 1 ? (innerW * i) / (data.length - 1) : innerW / 2)
const y = (v) => pad.t + innerH - (innerH * v) / top
const points = data.map((d, i) => ({ x: round(x(i)), y: round(y(Number(d.pv) || 0)), day: d.day, pv: Number(d.pv) || 0 }))
const points = data.map((d, i) => ({ x: round(x(i)), y: round(y(Number(d.pv) || 0)), day: d.day, pv: Number(d.pv) || 0, prev: prevOf(i) }))
const step = Math.max(1, Math.ceil(data.length / maxLabels))
const xLabels = points.filter((_, i) => i % step === 0 || i === data.length - 1).filter((_, i, a) => i === a.length - 1 || a[a.length - 1].x - a[i].x > 36).map((p) => ({ x: p.x, label: shortDay(p.day) }))
const xLabels = points.filter((_, i) => i % step === 0 || i === data.length - 1).filter((_, i, a) => i === a.length - 1 || a[a.length - 1].x - a[i].x > labelGap).map((p) => ({ x: p.x, label: shortDay(p.day) }))
const yTicks = [0, 0.5, 1].map((f) => ({ y: round(y(top * f)), label: String(Math.round(top * f)) }))
const line = points.map((p, i) => `${i ? 'L' : 'M'}${p.x} ${p.y}`).join(' ')
const baseY = round(pad.t + innerH)
const area = points.length ? `${line} L${points[points.length - 1].x} ${baseY} L${points[0].x} ${baseY} Z` : ''
return { width, height, pad, top, points, xLabels, yTicks, line, area }
// Vergleichslinie: Luecken (fehlende Tage) beginnen einen neuen Abschnitt
const prevLine = points.reduce((path, p, i) => {
if (p.prev === null) return path
const move = i > 0 && points[i - 1].prev !== null ? 'L' : 'M'
return `${path}${path ? ' ' : ''}${move}${p.x} ${round(y(p.prev))}`
}, '')
return { width, height, pad, top, points, xLabels, yTicks, line, area, prevLine }
}
function round(n) {
@@ -90,3 +103,251 @@ export function controllerLines(controller) {
const c = controller || {}
return [c.company, c.address, c.email].map((v) => String(v || '').trim()).filter(Boolean)
}
/* Analyse-Zustand je Website (comingSoon = Schalter aus) und Auswahl */
export const analyseOf = (project) => project.analyse || { comingSoon: true }
export const analyseState = (a) => (a.comingSoon ? 'soon' : a.state === 'unknown' ? 'unknown' : a.state === 'disabled' ? 'disabled' : a.state === 'active' ? 'active' : a.eligible ? 'off' : 'blocked')
export function pickAnalyseProject(projects) {
return projects.find((p) => analyseState(analyseOf(p)) === 'active') || projects.find((p) => analyseState(analyseOf(p)) === 'off') || projects[0]
}
// Gemeinsam fuer die Kachel (funktionen.js) und die Analyse-Ansicht (analyse.js)
export const analyseSel = { projectId: '', range: 30, tab: 'uebersicht' }
/* --- Vergleich zum Vorzeitraum und Klartext ("Das Wichtigste"), Spec 2.3 und 2.4 --- */
const MONTHS = ['Jan.', 'Feb.', 'März', 'Apr.', 'Mai', 'Juni', 'Juli', 'Aug.', 'Sep.', 'Okt.', 'Nov.', 'Dez.']
const WEEKDAYS = ['Sonntag', 'Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag'] // wie Date#getUTCDay()
const WEEKDAYS_SHORT = ['So.', 'Mo.', 'Di.', 'Mi.', 'Do.', 'Fr.', 'Sa.']
const AGAINST = 'gegenüber dem Zeitraum davor'
const NUMBER_DE = new Intl.NumberFormat('de-DE', { maximumFractionDigits: 0 })
const isNum = (v) => typeof v === 'number' && Number.isFinite(v)
/** Ganzzahl auf Deutsch: 6930 => "6.930". */
export const fmtNum = (n) => NUMBER_DE.format(Number(n) || 0)
/** Sekunden als m:ss: 108 => "1:48", 7 => "0:07"; null oder ungueltig => "–". */
export function fmtDuration(sec) {
if (!isNum(sec) || sec < 0) return '–'
const s = Math.round(sec)
return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`
}
// "YYYY-MM-DD" => { y, m, d } oder null; Wochentage und Tagesrechnung laufen nur ueber Date.UTC (keine Ortszeit)
function parseDay(day) {
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(day || '')
if (!m) return null
const [y, mo, d] = [+m[1], +m[2], +m[3]]
const t = new Date(Date.UTC(y, mo - 1, d))
return t.getUTCMonth() === mo - 1 && t.getUTCDate() === d ? { y, m: mo, d } : null
}
const weekdayOf = (day) => {
const p = parseDay(day)
return p ? new Date(Date.UTC(p.y, p.m - 1, p.d)).getUTCDay() : -1
}
function addDays(day, n) {
const p = parseDay(day)
return p ? new Date(Date.UTC(p.y, p.m - 1, p.d + n)).toISOString().slice(0, 10) : ''
}
/** "2026-09-19" => "Sa., 19.09." */
export function dayLabel(day) {
const w = weekdayOf(day)
return w < 0 ? String(day || '') : `${WEEKDAYS_SHORT[w]}, ${shortDay(day)}`
}
/** ("2026-09-05", "2026-10-04") => "5. Sep. – 4. Okt."; leere oder ungueltige Eingabe => "". */
export function periodLabel(from, to) {
const a = parseDay(from)
const b = parseDay(to)
return a && b ? `${a.d}. ${MONTHS[a.m - 1]} – ${b.d}. ${MONTHS[b.m - 1]}` : ''
}
/** Vergleich moeglich, wenn der Vorzeitraum vollstaendig ist und mindestens 20 Aufrufe hat. Sonst from = "TT.MM." ab dem der Vergleich geht (nur bei Vorzeitraum, der noch nicht voll ist).
Der Zeitraum endet gestern: der Vorzeitraum beginnt heute - 2 x range, also ist er ab firstDay + 2 x range voll. */
export function compareFrom(stats) {
const prev = stats?.previous
if (prev?.complete && Number(prev.totals?.pv) >= 20) return { ok: true, from: '' }
const range = Number(stats?.range)
if (prev && !prev.complete && stats.firstDay && range > 0) {
const from = addDays(stats.firstDay, 2 * range)
return { ok: false, from: from ? shortDay(from) : '' }
}
return { ok: false, from: '' }
}
// Sekunden in Worten fuer Screenreader: 65 => "1 Minute 5 Sekunden"
function secWords(sec) {
const m = Math.floor(sec / 60)
const s = sec % 60
return [m ? `${m} ${m === 1 ? 'Minute' : 'Minuten'}` : '', s || !m ? `${s} ${s === 1 ? 'Sekunde' : 'Sekunden'}` : ''].filter(Boolean).join(' ')
}
/** Veraenderung gegenueber dem Vorwert fuer den Chip: { dir, text, aria } oder null, wenn ein Wert keine Zahl ist. kind 'count': Prozent ab 100/100, sonst absolut; kind 'sec': absolut in Sekunden. */
export function delta(cur, prev, { kind = 'count' } = {}) {
if (!isNum(cur) || !isNum(prev)) return null
const flat = (text) => ({ dir: 'flat', text, aria: `unverändert ${AGAINST}` })
const diff = cur - prev
if (diff === 0) return flat('± 0')
const dir = diff > 0 ? 'up' : 'down'
const arrow = diff > 0 ? '▲' : '▼'
const sign = diff > 0 ? 'plus' : 'minus'
if (kind === 'sec') {
const n = Math.round(Math.abs(diff))
if (n === 0) return flat('± 0')
return { dir, text: `${arrow} ${n < 60 ? `${n} s` : fmtDuration(n)}`, aria: `${sign} ${secWords(n)} ${AGAINST}` }
}
if (cur >= 100 && prev >= 100) {
const pct = (Math.abs(diff) / prev) * 100
if (pct < 1) return flat('± 0 %')
const n = Math.round(pct)
return { dir, text: `${arrow} ${n} %`, aria: `${sign} ${n} Prozent ${AGAINST}` }
}
const n = fmtNum(Math.abs(diff))
return { dir, text: `${arrow} ${n}`, aria: `${sign} ${n} ${AGAINST}` }
}
/** Kontakt-Aktionen pro 100 Besucher, eine Nachkommastelle; null unter 30 Besuchern (zu unsicher). */
export function contactRate(contacts, visitors) {
if (!isNum(contacts) || !isNum(visitors) || visitors < 30) return null
return Math.round((contacts / visitors) * 1000) / 10
}
// Bekannte Herkunfts-Hosts (Teilstring am Label-Rand: "l.facebook.com" ja, "notgoogle.com" nein)
const REF_NAMES = [
[/(^|\.)google\.(com?\.)?[a-z]{2,3}$/, 'Google'],
[/(^|\.)bing\.com$/, 'Bing'],
[/(^|\.)instagram\.com$/, 'Instagram'],
[/(^|\.)facebook\.com$/, 'Facebook'],
[/(^|\.)duckduckgo\.com$/, 'DuckDuckGo'],
[/(^|\.)ecosia\.org$/, 'Ecosia'],
]
/** Lesbarer Name einer Herkunft; unbekannter Host bleibt unveraendert (beim Einsetzen in HTML maskieren). */
export function refName(host) {
const h = String(host ?? '').trim()
if (!h) return 'Direkt oder unbekannt'
const hit = REF_NAMES.find(([re]) => re.test(h.toLowerCase()))
return hit ? hit[1] : h
}
/** Staerkster Wochentag und Spitzentag ueber die (abgeschlossenen) Tage der Reihe ab firstDay ('' = alle). null bei weniger als 7 Tagen. */
export function weekdayStats(series, firstDay) {
const rows = (Array.isArray(series) ? series : [])
.filter((d) => parseDay(d?.day) && (!firstDay || d.day >= firstDay))
.map((d) => ({ day: d.day, pv: Number(d.pv) || 0 }))
if (rows.length < 7) return null
const avg = rows.reduce((a, r) => a + r.pv, 0) / rows.length
const perWeekday = Array.from({ length: 7 }, () => ({ sum: 0, n: 0 }))
for (const r of rows) {
const w = perWeekday[weekdayOf(r.day)]
w.sum += r.pv
w.n += 1
}
let best = null
perWeekday.forEach((w, weekday) => {
if (w.n && (!best || w.sum / w.n > best.avg)) best = { weekday, name: WEEKDAYS[weekday], avg: w.sum / w.n }
})
if (!best || !(avg > 0) || best.avg < 1.2 * avg) best = null
const max = rows.reduce((m, r) => (r.pv > m.pv ? r : m), rows[0])
const peak = avg > 0 && max.pv >= 1.8 * avg ? { day: max.day, pv: max.pv } : null
return { best, avg, peak }
}
// Direkte Kontaktwege; Klicks auf Links nach außen ('out') sind kein Kontakt zu dir und zählen im Satz nicht mit
const CONTACT_WAYS = { tel: 'per Telefon', mail: 'per E-Mail', wa: 'per WhatsApp' }
const DEVICE_WORDS = { m: 'vom Handy', d: 'vom Computer' }
// Satz ueber dem Hinweis-Block; nur hier steht fett (<b>), alle Zahlen sind fmtNum
function leadOf(stats, cmp) {
const days = Number(stats.range) || (Array.isArray(stats.series) ? stats.series.length : 0)
const pv = Number(stats.totals.pv)
const count = `<b>${fmtNum(pv)}-mal</b>`
if (!cmp.ok) {
return stats.firstDay && stats.from && stats.firstDay >= stats.from
? `Seit dem Start der Analyse am ${esc(shortDay(stats.firstDay))} wurde deine Website ${count} aufgerufen.`
: `Deine Website wurde in den letzten ${days} Tagen ${count} aufgerufen.`
}
const before = Number(stats.previous.totals.pv)
const diff = pv - before
const start = `Deine Website wurde in den letzten ${days} Tagen ${count} aufgerufen – `
if (Math.abs(diff) / before < 0.05) return `${start}etwa so oft wie in den ${days} Tagen davor.`
const more = diff > 0
const size = pv >= 100 && before >= 100
? `${fmtNum(Math.round((Math.abs(diff) / before) * 100))} %`
: `${fmtNum(Math.abs(diff))} ${Math.abs(diff) === 1 ? 'Aufruf' : 'Aufrufe'}`
return `${start}<b>${size} ${more ? 'mehr' : 'weniger'}</b> als in den ${days} Tagen davor.`
}
function contactItem(stats, cmp) {
if (!Array.isArray(stats.contacts)) return null // Collector ohne Kontaktliste: nichts behaupten
const direct = (rows) => (Array.isArray(rows) ? rows : []).filter((c) => Object.hasOwn(CONTACT_WAYS, c?.key) && c.count > 0)
const sum = (rows) => rows.reduce((a, c) => a + c.count, 0)
const cur = direct(stats.contacts)
const total = sum(cur)
if (total < 1) {
return Number(stats.totals.visitors) >= 30
? 'Von den Besuchern mit Zustimmung hat noch niemand über die Website angerufen oder geschrieben.'
: null
}
const main = cur.reduce((m, c) => (!m || c.count > m.count ? c : m), null)
let text = `${fmtNum(total)}-mal hat dich jemand direkt kontaktiert, meist ${CONTACT_WAYS[main.key]} (${fmtNum(main.count)}).`
const before = cmp.ok ? sum(direct(stats.previous.contacts)) : 0
if (before >= 1) {
const diff = total - before
text += diff === 0 ? ' Genauso oft wie zuvor.' : ` Das sind ${fmtNum(Math.abs(diff))} ${diff > 0 ? 'mehr' : 'weniger'} als zuvor.`
}
return text
}
function dayItem(stats) {
if (!(Number(stats.totals.pv) >= 70)) return null
const w = weekdayStats(stats.series, stats.firstDay || '')
if (!w) return null
const parts = []
if (w.best) parts.push(`${w.best.name} ist im Schnitt dein stärkster Tag.`)
if (w.peak) parts.push(`Spitzentag war ${dayLabel(w.peak.day)} mit ${fmtNum(w.peak.pv)} Aufrufen.`)
return parts.length ? parts.join(' ') : null
}
function sourceItem(stats) {
const refs = Array.isArray(stats.refs) ? stats.refs : []
const sum = refs.reduce((a, r) => a + (Number(r.count) || 0), 0)
const first = refs.find((r) => r.key)
if (!first || !(sum > 0) || first.count / sum < 0.2) return null
return `Die meisten, die über einen Link kamen, kommen von ${esc(refName(first.key))} (${fmtNum(first.count)}).`
}
function deviceItem(stats) {
const devices = Array.isArray(stats.devices) ? stats.devices : []
const sum = devices.reduce((a, d) => a + (Number(d.count) || 0), 0)
const main = devices.find((d) => DEVICE_WORDS[d.key] && sum > 0 && d.count / sum >= 0.55)
return main ? `${fmtNum(Math.round((main.count / sum) * 100))} % der Aufrufe kommen ${DEVICE_WORDS[main.key]}.` : null
}
function pageItem(stats) {
const [first, second] = Array.isArray(stats.pages) ? stats.pages : []
if (!first?.key) return null
if (first.key === '/') {
return second?.key
? `Am meisten angesehen: die Startseite, danach ${esc(second.key)} (${fmtNum(second.count)}).`
: `Am meisten angesehen: die Startseite (${fmtNum(first.count)} Aufrufe).`
}
return `Am meisten angesehen: ${esc(first.key)} (${fmtNum(first.count)} Aufrufe).`
}
/** Klartext fuer "Das Wichtigste": { lead, items: [{ icon, html }] } als maskiertes HTML; null unter 30 Seitenaufrufen. Feste Regeln, keine KI. */
export function summarize(stats) {
if (!stats?.totals || !(Number(stats.totals.pv) >= 30)) return null
const cmp = compareFrom(stats)
const items = [
['contact', contactItem(stats, cmp)],
['day', dayItem(stats)],
['source', sourceItem(stats)],
['device', deviceItem(stats)],
['page', pageItem(stats)],
].filter(([, html]) => html).slice(0, 4).map(([icon, html]) => ({ icon, html }))
return { lead: leadOf(stats, cmp), items }
}
+295
View File
@@ -0,0 +1,295 @@
/* Analyse "Cockpit" (analyse.js, analyse-cards.js): alles unter dem Wurzelelement .an mit eigenen Variablen und eigenem tuerkisen Akzent;
ausserhalb von #view-analyse aendert sich nichts. Klassen und Zustaende: Vertrag im Kopf von analyse-cards.js. */
.an {
--an-fg: #fff;
--an-fg2: rgba(255, 255, 255, 0.78);
--an-muted: rgba(255, 255, 255, 0.58);
--an-faint: rgba(255, 255, 255, 0.55); /* >= 4,5:1 auf der Karte (gemessen ca. 6,2:1) */
--an-line: rgba(255, 255, 255, 0.09);
--an-line2: rgba(255, 255, 255, 0.18);
--an-card: rgba(14, 16, 20, 0.92);
--an-acc: #2dd4bf;
--an-acc-strong: #5eead4;
--an-acc-soft: rgba(45, 212, 191, 0.1);
--an-on-acc: #04201c;
--an-up-bg: rgba(45, 212, 191, 0.14);
--an-down: #fda4af;
--an-down-bg: rgba(244, 63, 94, 0.14);
--an-prev: rgba(255, 255, 255, 0.35);
--an-warn: #fcd34d;
--an-bar-fill: linear-gradient(90deg, rgba(45, 212, 191, 0.32), rgba(45, 212, 191, 0.12));
--an-r: 16px;
--an-gap: 14px;
min-width: 0;
color: var(--an-fg);
color-scheme: dark;
}
.an, .an *, .an *::before, .an *::after { box-sizing: border-box; }
.an a, .an .kb-link { color: var(--an-acc-strong); }
.an :is(button, select, input, a):focus-visible { outline: 2px solid var(--an-acc-strong); outline-offset: 2px; }
/* ------------------------------------------------------------ Kopfleiste --- */
.an-top { display: flex; align-items: center; justify-content: space-between; gap: 12px 16px; flex-wrap: wrap; padding: 14px 16px; border-radius: var(--an-r); background: var(--an-card); border: 1px solid var(--an-line); }
.an-site { --an-dot: #2dd4bf; --an-dot-soft: rgba(45, 212, 191, 0.18); display: flex; align-items: center; gap: 12px; flex: 1 1 260px; min-width: 0; }
.an-site[data-state="warn"] { --an-dot: #fcd34d; --an-dot-soft: rgba(252, 211, 77, 0.18); }
.an-site[data-state="off"] { --an-dot: rgba(255, 255, 255, 0.4); --an-dot-soft: rgba(255, 255, 255, 0.1); }
.an-site > div { min-width: 0; }
.an-status-dot { flex: none; width: 9px; height: 9px; border-radius: 50%; background: var(--an-dot); box-shadow: 0 0 0 4px var(--an-dot-soft); }
.an-site-name { display: block; font-size: 16px; font-weight: 700; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.an-status { display: block; margin-top: 2px; font-size: 12px; color: var(--an-muted); }
.an-st { white-space: nowrap; }
.an-pick {
display: block; max-width: 100%; padding: 2px 24px 2px 0; border: 0; border-radius: 6px; cursor: pointer; text-overflow: ellipsis;
-webkit-appearance: none; appearance: none; color: var(--an-fg); font-family: inherit; font-size: 16px; font-weight: 700; line-height: 1.3;
background: transparent url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12'><path d='M2 4.5l4 4 4-4' fill='none' stroke='white' stroke-opacity='.7' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'/></svg>") no-repeat right 2px center / 12px;
}
.an-pick option { background: #14161b; color: #fff; }
.an-seg { display: inline-flex; flex: none; gap: 2px; padding: 3px; border-radius: 11px; background: rgba(255, 255, 255, 0.04); border: 1px solid var(--an-line); }
.an-seg button { padding: 7px 13px; border: 0; border-radius: 8px; background: transparent; color: var(--an-muted); font: inherit; font-size: 13px; font-weight: 600; cursor: pointer; transition: background-color 0.15s, color 0.15s; }
.an-seg button:hover { color: var(--an-fg); }
.an-seg button.on { background: var(--an-acc); color: var(--an-on-acc); }
/* ----------------------------------------------------------------- Reiter --- */
.an-tabs { display: flex; gap: 22px; margin: 18px 4px; border-bottom: 1px solid var(--an-line); overflow-x: auto; scrollbar-width: none; }
.an-tabs::-webkit-scrollbar { display: none; }
.an-tabbtn { flex: none; padding: 0 0 12px; border: 0; background: transparent; color: var(--an-muted); font: inherit; font-size: 14px; font-weight: 600; white-space: nowrap; cursor: pointer; transition: color 0.15s; }
.an-tabbtn:hover { color: var(--an-fg); }
.an-tabbtn.on { color: var(--an-fg); box-shadow: inset 0 -2px 0 var(--an-acc); }
.an .an-tabbtn:focus-visible { outline-offset: -2px; border-radius: 4px; }
.an-top + #an-stage { margin-top: var(--an-gap); }
/* ------------------------------------------------------- Karten und Raster --- */
.an-ov { display: grid; gap: var(--an-gap); min-width: 0; }
.an-ov > * { min-width: 0; }
.an-card { min-width: 0; padding: 20px 22px; border-radius: var(--an-r); background: var(--an-card); border: 1px solid var(--an-line); }
.an-card h3 { margin: 0; font-size: 13px; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; color: var(--an-muted); }
.an-card > h3 { margin-bottom: 14px; }
.an-head { display: flex; align-items: center; justify-content: space-between; gap: 8px 16px; flex-wrap: wrap; margin-bottom: 14px; }
.an-empty { margin: 0; color: var(--an-muted); font-size: 14px; line-height: 1.5; }
.an-credit { margin: 2px 4px 0; font-size: 11.5px; line-height: 1.5; color: var(--an-faint); }
.an .an-credit a { color: inherit; }
/* Das Wichtigste */
.an-sum { padding: 22px 24px; border-radius: var(--an-r); background: linear-gradient(135deg, rgba(45, 212, 191, 0.13), rgba(139, 92, 246, 0.1) 60%, rgba(14, 16, 20, 0.9)); border: 1px solid rgba(94, 234, 212, 0.35); }
.an-eyebrow { margin: 0 0 10px; font-size: 12px; font-weight: 700; letter-spacing: 0.14em; text-transform: uppercase; color: var(--an-acc); }
.an-lead { margin: 0 0 14px; font-size: 21px; line-height: 1.4; font-weight: 600; }
.an-sum-empty .an-lead { margin: 0; font-size: 17px; font-weight: 500; color: var(--an-fg2); }
.an-lead b { color: var(--an-acc-strong); white-space: nowrap; }
.an-points { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px 22px; margin: 0; padding: 0; list-style: none; }
.an-points li { display: flex; align-items: flex-start; gap: 10px; color: var(--an-fg2); line-height: 1.45; }
.an-points li > span:last-child { min-width: 0; overflow-wrap: anywhere; }
.an-points b { color: var(--an-fg); }
.an-point-ico { flex: none; display: grid; place-items: center; width: 26px; height: 26px; border-radius: 8px; background: var(--an-acc-soft); color: var(--an-acc-strong); }
.an-ico { width: 15px; height: 15px; }
/* Kennzahlen */
.an-kpis { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: var(--an-gap); }
.an-kpi { display: flex; flex-direction: column; padding: 16px 18px 12px; }
.an-k { display: flex; align-items: center; gap: 4px; font-size: 13px; font-weight: 500; color: var(--an-muted); }
.an-star { font-size: 11px; color: var(--an-faint); }
.an-v { display: block; margin: 6px 0 4px; font-size: 30px; font-weight: 700; line-height: 1.15; letter-spacing: -0.02em; font-variant-numeric: tabular-nums; }
.an-sub { display: block; margin: -2px 0 4px; font-size: 12px; color: var(--an-muted); }
.an-cmp { display: flex; align-items: center; flex-wrap: wrap; gap: 4px 6px; min-height: 24px; margin-bottom: 10px; }
.an-vs { font-size: 12px; color: var(--an-faint); }
.an-delta { display: inline-flex; align-items: center; gap: 4px; padding: 3px 8px; border-radius: 999px; font-size: 12px; font-weight: 700; white-space: nowrap; }
.an-delta.up { background: var(--an-up-bg); color: var(--an-acc-strong); }
.an-delta.down { background: var(--an-down-bg); color: var(--an-down); }
.an-delta.flat { background: var(--an-acc-soft); color: var(--an-muted); }
.an-spark { display: block; width: 100%; height: 38px; margin-top: auto; }
.an-spark-area { fill: var(--an-acc); opacity: 0.14; stroke: none; }
.an-spark-line { stroke: var(--an-acc); stroke-width: 1.6px; stroke-linejoin: round; stroke-linecap: round; }
/* Diagramm und Kontakt-Aktionen: gleich hoch, das Diagramm fuellt die Karte */
.an-row2 { display: grid; grid-template-columns: minmax(0, 1.65fr) minmax(0, 1fr); gap: var(--an-gap); align-items: stretch; }
.an-chartbox, .an-contacts { display: flex; flex-direction: column; }
.an-legend { display: flex; flex-wrap: wrap; gap: 4px 16px; font-size: 12px; font-weight: 500; color: var(--an-muted); }
.an-leg { display: inline-flex; align-items: center; gap: 6px; white-space: nowrap; }
.an-leg-line { display: inline-block; width: 18px; height: 0; border-top: 3px solid var(--an-acc); border-radius: 2px; }
.an-leg-prev { border-top: 2px dashed var(--an-prev); }
.an-chartwrap { position: relative; flex: 1 1 auto; display: flex; align-items: center; min-height: 0; container-type: inline-size; }
.an-chart { display: block; width: 100%; height: auto; max-height: 100%; overflow: visible; touch-action: pan-y; }
.an-grid { stroke: var(--an-line); stroke-width: 1px; vector-effect: non-scaling-stroke; }
/* Achsenschrift in viewBox-Einheiten (viewBox 640 breit, die Zeichnung skaliert mit der Kartenbreite). Je nach Breite der Zeichnung
so gewaehlt, dass sie immer ca. 11-12 px gross erscheint (gemessen: 320 px Handy bis 1920 px Desktop); Grundwert fuer Browser ohne Container-Abfragen. */
.an-axis { fill: var(--an-muted); font-size: 13px; }
@container (max-width: 279px) { .an-axis { font-size: 28px; } }
@container (min-width: 280px) and (max-width: 329px) { .an-axis { font-size: 24px; } }
@container (min-width: 330px) and (max-width: 379px) { .an-axis { font-size: 21px; } }
@container (min-width: 380px) and (max-width: 439px) { .an-axis { font-size: 18px; } }
@container (min-width: 440px) and (max-width: 499px) { .an-axis { font-size: 16px; } }
@container (min-width: 500px) and (max-width: 559px) { .an-axis { font-size: 14px; } }
@container (min-width: 560px) and (max-width: 629px) { .an-axis { font-size: 12.5px; } }
@container (min-width: 630px) and (max-width: 699px) { .an-axis { font-size: 11px; } }
@container (min-width: 700px) { .an-axis { font-size: 10px; } }
.an-area { fill: var(--an-acc); opacity: 0.12; stroke: none; }
.an-prev { fill: none; stroke: var(--an-prev); stroke-width: 2px; stroke-dasharray: 5 5; stroke-linejoin: round; vector-effect: non-scaling-stroke; }
.an-line { fill: none; stroke: var(--an-acc); stroke-width: 2.4px; stroke-linejoin: round; stroke-linecap: round; vector-effect: non-scaling-stroke; }
/* Punkte erst beim Zeigen oder Antippen (analyse.js setzt "on"); der Rand ist ein weicher Ring */
.an-dot { fill: var(--an-acc-strong); stroke: rgba(45, 212, 191, 0.3); stroke-width: 9px; vector-effect: non-scaling-stroke; opacity: 0; pointer-events: none; }
.an-dot.on { opacity: 1; }
.an-tip { position: absolute; z-index: 3; padding: 6px 10px; border-radius: 8px; background: #0b0d10; border: 1px solid var(--an-line2); box-shadow: 0 6px 18px rgba(0, 0, 0, 0.5); color: var(--an-fg); font-size: 12.5px; font-weight: 600; white-space: nowrap; pointer-events: none; transform: translate(-50%, calc(-100% - 12px)); }
.an-tip.below { transform: translate(-50%, 12px); }
.an-tip[hidden] { display: none; }
.an-sum-n { font-size: 15px; font-weight: 700; color: var(--an-fg2); font-variant-numeric: tabular-nums; }
.an-crows { margin: 0 0 12px; padding: 0; list-style: none; }
.an-crow { display: grid; grid-template-columns: 34px minmax(0, 1fr) auto auto; align-items: center; gap: 10px; padding: 8px 0; border-top: 1px solid var(--an-line); }
.an-cico { display: grid; place-items: center; width: 34px; height: 34px; border-radius: 10px; background: var(--an-acc-soft); color: var(--an-acc-strong); }
.an-cico .an-ico { width: 17px; height: 17px; }
.an-cname { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.an-crow b { font-size: 16px; font-variant-numeric: tabular-nums; text-align: right; }
.an-quote { margin: auto 0 0; padding: 10px 12px; border-radius: 10px; background: var(--an-acc-soft); color: var(--an-fg2); font-size: 13px; line-height: 1.45; }
/* Ranglisten */
.an-pair { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--an-gap); }
.an-ltabs { display: flex; flex-wrap: wrap; gap: 6px; margin: -4px 0 12px; }
.an-ltab { padding: 6px 11px; border: 1px solid var(--an-line); border-radius: 8px; background: transparent; color: var(--an-muted); font: inherit; font-size: 12.5px; font-weight: 600; cursor: pointer; transition: background-color 0.15s, color 0.15s; }
.an-ltab:hover { color: var(--an-fg); }
.an-ltab.on { background: var(--an-acc-soft); border-color: var(--an-line2); color: var(--an-fg); }
.an-lrows { margin: 0; padding: 0; list-style: none; }
.an-lr { display: grid; grid-template-columns: minmax(0, 1fr) auto 52px; align-items: center; gap: 12px; padding: 7px 0; }
.an-lab { position: relative; display: block; min-width: 0; padding: 5px 8px; border-radius: 7px; overflow: hidden; }
.an-fill { position: absolute; inset: 0 auto 0 0; border-radius: 7px; background: var(--an-bar-fill); }
.an-name { position: relative; display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.an-lr b { font-size: 14px; font-variant-numeric: tabular-nums; }
.an-lr em { font-size: 12px; font-style: normal; text-align: right; color: var(--an-muted); font-variant-numeric: tabular-nums; }
.an-more { margin-top: 8px; padding: 7px 12px; border: 1px solid var(--an-line); border-radius: 8px; background: transparent; color: var(--an-fg2); font: inherit; font-size: 12.5px; font-weight: 600; cursor: pointer; }
.an-more:hover { color: var(--an-fg); border-color: var(--an-line2); }
/* Datenbasis und Heatmap-Karte */
.an-basis-row { display: grid; grid-template-columns: auto minmax(0, 1fr); align-items: center; gap: 18px; }
.an-ring { width: 74px; height: 74px; }
.an-ring-bg { stroke: rgba(255, 255, 255, 0.1); }
.an-ring-fg { stroke: var(--an-acc); }
.an-ring-n { fill: var(--an-fg); font-size: 15px; font-weight: 700; }
.an-basis-text { margin: 0; font-size: 13.5px; line-height: 1.5; color: var(--an-fg2); }
.an-basis-main { display: block; margin-bottom: 2px; font-weight: 600; color: var(--an-fg); }
.an-teaser-row { display: flex; align-items: center; flex-wrap: wrap; gap: 14px 18px; }
.an-teaser-row p { flex: 1 1 170px; min-width: 0; margin: 0; line-height: 1.5; color: var(--an-fg2); }
.an-mini-heat {
flex: none; width: 110px; height: 74px; border-radius: 10px; border: 1px solid var(--an-line);
background:
radial-gradient(circle at 22% 40%, rgba(255, 60, 40, 0.9), rgba(255, 200, 0, 0.5) 12%, transparent 24%),
radial-gradient(circle at 70% 62%, rgba(255, 60, 40, 0.85), rgba(255, 200, 0, 0.5) 10%, transparent 22%),
radial-gradient(circle at 48% 25%, rgba(80, 220, 120, 0.7), transparent 14%), #1b1d22;
}
.an-btn { flex: none; padding: 10px 16px; border: 0; border-radius: 10px; background: var(--an-acc); color: var(--an-on-acc); font: inherit; font-size: 13px; font-weight: 700; white-space: nowrap; cursor: pointer; transition: background-color 0.15s; }
.an-btn:hover { background: var(--an-acc-strong); }
/* Laden und Fehler */
.an-sr { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }
.an-sk { position: relative; overflow: hidden; }
.an-sk > i { display: block; height: 14px; margin-top: 12px; border-radius: 7px; background: rgba(255, 255, 255, 0.07); }
.an-sk > i:first-child { width: 38%; margin-top: 0; }
.an-sk > i:nth-child(2) { width: 62%; height: 28px; }
.an-sk > i:nth-child(3) { width: 85%; }
.an-sk::after { content: ''; position: absolute; inset: 0; background: linear-gradient(100deg, transparent 30%, rgba(255, 255, 255, 0.05) 50%, transparent 70%) 150% 0 / 200% 100%; animation: an-shimmer 1.4s linear infinite; }
@keyframes an-shimmer { to { background-position: -50% 0; } }
.an-skeleton .an-sum { min-height: 150px; }
.an-skeleton .an-kpi { min-height: 128px; }
.an-skeleton .an-chartbox, .an-skeleton .an-contacts { min-height: 300px; }
.an-skeleton .an-rank { min-height: 230px; }
.an-error { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 14px 16px; }
.an-error p { margin: 0; }
/* ---------------------------------------------------------- Einstellungen --- */
.an-dl { margin: 0; }
.an-dl > div { display: flex; justify-content: space-between; align-items: baseline; gap: 16px; padding: 10px 0; border-top: 1px solid var(--an-line); }
.an-dl dt { color: var(--an-muted); }
.an-dl dd { margin: 0; text-align: right; overflow-wrap: anywhere; }
.an-swatch { display: inline-block; width: 14px; height: 14px; margin-right: 4px; border-radius: 4px; border: 1px solid var(--an-line2); vertical-align: -2px; }
.an-docs ul { margin: 0; padding: 0; list-style: none; }
.an-docs li { padding: 8px 0; border-top: 1px solid var(--an-line); }
#an-off p { margin: 0 0 12px; line-height: 1.55; color: var(--an-fg2); }
.an-confirm { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; font-size: 13px; }
.an-ghost { padding: 9px 16px; border: 1px solid var(--an-line2); border-radius: 10px; background: transparent; color: inherit; font: inherit; font-size: 13px; font-weight: 700; cursor: pointer; }
.an-ghost:hover { background: rgba(255, 255, 255, 0.07); }
.an-danger { color: #fca5a5; }
.an .error { margin: 0 0 12px; text-align: left; }
#an-off p.error { color: #fca5a5; }
/* ------------------------------------------- Einschalten und andere Zustaende --- */
.an-state p, .an-panel p { margin: 0 0 12px; line-height: 1.55; color: var(--an-fg2); }
.an-state p:last-child { margin-bottom: 0; }
.an-panel > p, .an-state p { max-width: 75ch; }
.an-panel > h3, .an-state > h3 { margin: 0 0 10px; font-size: 20px; font-weight: 700; letter-spacing: 0; text-transform: none; color: var(--an-fg); }
.an-h4 { margin: 20px 0 8px; font-size: 14px; font-weight: 700; }
.an-colors { display: flex; align-items: center; flex-wrap: wrap; gap: 12px; }
.an-colors input[type=color] { width: 48px; height: 40px; padding: 2px; border-radius: 10px; border: 1px solid var(--an-line2); background: transparent; cursor: pointer; }
.an-presets { display: flex; flex-wrap: wrap; gap: 8px; }
.an-preset { width: 32px; height: 32px; padding: 0; border-radius: 50%; border: 2px solid var(--an-line2); cursor: pointer; }
.an-hint { margin: 10px 0 0 !important; font-size: 13px; color: var(--an-warn) !important; }
.an-ctl { margin: 14px 0; padding: 12px 14px; border-radius: 10px; background: var(--an-acc-soft); }
.an-ctl p { margin: 0 0 6px; }
.an-ctl p:last-child { margin: 0; }
.an .fn-consent { max-width: 75ch; color: var(--an-fg2); }
.an .fn-consent input { accent-color: var(--an-acc); }
.an .abo-btn { background: var(--an-acc); color: var(--an-on-acc); border-radius: 10px; }
.an .abo-btn:hover { background: var(--an-acc-strong); }
/* Banner-Vorschau: gleiche Optik wie der echte Banner (analytics/client/w.js), immer hell */
.an-bn { margin: 16px 0; padding: 14px 20px; display: flex; align-items: center; gap: 16px; flex-wrap: wrap; background: #fff; color: #111; border: 1px solid #d0d0d0; border-radius: 12px; font: 15px/1.45 system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif; }
.an .an-bn-text { flex: 1 1 260px; min-width: 0; margin: 0 !important; max-width: none; color: #111 !important; font-size: 15px !important; line-height: 1.45 !important; }
.an-bn-link { text-decoration: underline; }
.an-bn-row { display: flex; gap: 10px; flex: none; }
.an-bn-btn { display: inline-flex; align-items: center; justify-content: center; box-sizing: border-box; width: 150px; height: 44px; padding: 0 20px; border: 2px solid; border-radius: 8px; font: 600 15px/1 system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif; }
/* ---------------------------------------------------------------- Heatmap --- */
/* Buehne: grosses Geraet (Computer oder Tablet) und Handy nebeneinander, unten buendig (Rahmen: geraete.css) */
.an-heat > p { margin: 0 0 14px; line-height: 1.55; color: var(--an-fg2); }
.an-heat .hs-head { margin-bottom: 10px; }
.an-heat-bar { display: flex; flex-wrap: wrap; align-items: flex-end; justify-content: space-between; gap: 12px; margin-bottom: 12px; }
.an-heat-bar label { display: grid; gap: 4px; flex: 1 1 220px; min-width: 0; max-width: 420px; font-size: 13px; font-weight: 600; }
.an-heat-bar select { width: 100%; min-width: 0; padding: 10px 12px; border-radius: 10px; border: 1px solid var(--an-line2); background: rgba(255, 255, 255, 0.05); color: inherit; font: inherit; text-overflow: ellipsis; }
.an-heat-bar select option { background: #14161b; color: #fff; }
.hs-stage { display: grid; grid-template-columns: minmax(0, 3.7fr) minmax(0, 1fr); align-items: end; gap: clamp(16px, 3vw, 40px); max-width: 980px; margin: 24px auto 4px; }
.hs-stage[data-big="t"] { grid-template-columns: minmax(0, 2.1fr) minmax(0, 1fr); max-width: 620px; }
.hs-dev { margin: 0; min-width: 0; display: flex; flex-direction: column; align-items: center; gap: 14px; }
.hs-frame { width: 100%; }
.hs-cap { min-height: 1.5em; font-size: 13px; line-height: 1.5; color: var(--an-muted); text-align: center; }
.hs-dev .dm-canvas { background: #fff; }
.hs-site { position: absolute; left: 0; background: #fff; }
.hs-site iframe { display: block; width: 100%; height: 100%; border: 0; background: #fff; }
@media (max-width: 640px) {
.hs-stage, .hs-stage[data-big="t"] { grid-template-columns: minmax(0, 1fr); gap: 28px; }
.hs-dev--phone .hs-frame { width: 62%; }
}
/* ------------------------------------------------------------ Mittlere Breite --- */
@media (max-width: 1100px) {
.an-kpis { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.an-row2 { grid-template-columns: minmax(0, 1fr); }
}
/* Heatmap-Karte: Vorschau, Text und Knopf in einer Zeile, solange die Karte breit genug ist */
@media (min-width: 1101px) {
.an-teaser-row { flex-wrap: nowrap; }
.an-teaser-row p { flex-basis: 0; }
}
/* ------------------------------------------------------------------- Handy --- */
@media (max-width: 880px) {
.an-top { padding: 14px; }
.an-seg { display: flex; width: 100%; }
.an-seg button { flex: 1 1 0; padding: 9px 8px; min-height: 40px; }
.an-tabs { gap: 20px; margin: 14px 2px; }
/* Tippflaechen mindestens 40 px hoch */
.an-tabbtn { min-height: 40px; padding-top: 8px; }
.an-ltab, .an-more, .an-btn { min-height: 40px; }
.an-ltab { display: inline-flex; align-items: center; padding: 6px 14px; }
.an-card { padding: 16px; }
.an-kpi { padding: 14px 14px 10px; }
.an-v { font-size: 26px; }
.an-row2, .an-pair, .an-points { grid-template-columns: minmax(0, 1fr); }
.an-sum { padding: 18px 16px; }
.an-lead { font-size: 19px; }
.an-sum-empty .an-lead { font-size: 16px; }
.an-heat-bar label { max-width: none; }
.an-bn { display: block; margin: 12px 0; padding: 16px; }
.an-bn-row { flex-direction: column; margin-top: 12px; }
.an-bn-btn { width: 100%; }
}
@media (prefers-reduced-motion: reduce) {
.an *, .an *::before, .an *::after { transition: none !important; animation: none !important; }
}
+518
View File
@@ -0,0 +1,518 @@
/* Analyse-Ansicht im Kundenbereich (ES-Modul, wird von app.js erst beim Oeffnen geladen).
initAnalyse(rootEl, { projectId }): Rahmen "Cockpit" (.an) mit Kopfleiste; je nach Zustand der Analyse Einschalten (off),
Hinweis (disabled/blocked/unknown) oder, bei aktiver Analyse, die Reiter Uebersicht | Heatmap | Einstellungen.
HTML-Bausteine: analyse-cards.js, reine Hilfsfunktionen und der gemeinsame Zustand: analyse-view.js. Styles: analyse.css.
Kundentexte kommen nur ueber esc() bzw. textContent in die Seite. */
import { esc, safeUrl, request } from './fn-util.js'
import { analyseOf, pickAnalyseProject, analyseSel, bannerColor, BANNER_PREVIEW_TEXT, controllerLines } from './analyse-view.js'
import { headerHtml, tabsHtml, overviewHtml, rankCardHtml, skeletonHtml, errorHtml, settingsHtml, chartTipText } from './analyse-cards.js'
import { HEAT_DEVICES, deviceHtml, heatCaption } from './geraete.js'
import { ZU_VERTRAEGE } from './vertraege-link.js'
const PRESET_COLORS = ['#111111', '#1d4ed8', '#047857', '#b91c1c', '#7c3aed', '#c2410c']
const HEX_RE = /^#[0-9a-f]{6}$/i
const nf = new Intl.NumberFormat('de-DE')
const TABS = ['uebersicht', 'heatmap', 'einstellungen']
const RANGES = [7, 30, 90]
const CLOCK = new Intl.DateTimeFormat('de-DE', { timeZone: 'Europe/Berlin', hour: '2-digit', minute: '2-digit', hourCycle: 'h23' })
const showError = (root) => (err) => { root.innerHTML = `<p class="muted">${esc(err.message)}</p>` }
export async function initAnalyse(root, { projectId } = {}) {
if (!root) return
if (projectId) analyseSel.projectId = projectId
const { projects = [] } = await request('/api/funktionen')
const usable = projects.filter((p) => !analyseOf(p).comingSoon)
if (!usable.length) {
root.innerHTML = '<div class="an"><section class="an-card an-state"><p class="an-empty">Die Analyse ist für deine Websites noch nicht freigeschaltet.</p></section></div>'
return
}
const project = usable.find((p) => p.projectId === analyseSel.projectId) || pickAnalyseProject(usable)
analyseSel.projectId = project.projectId
const a = analyseOf(project)
const active = a.state === 'active'
const tab = TABS.includes(analyseSel.tab) ? analyseSel.tab : 'uebersicht'
root.innerHTML = `<div class="an">${headHtml(project, usable, { showRange: active && tab === 'uebersicht' })}${active ? tabsHtml(tab) : ''}<div id="an-stage"${active ? ` role="tabpanel" aria-labelledby="an-tab-${tab}"` : ''}></div></div>`
const stage = root.querySelector('#an-stage')
const reload = () => initAnalyse(root).catch(showError(root))
// Website wechseln: der Reiter bleibt (analyseSel.tab)
root.querySelector('.an')?.addEventListener('change', (ev) => {
if (ev.target?.id !== 'an-project') return
analyseSel.projectId = ev.target.value
reload()
})
if (a.state === 'active') mountCockpit({ an: root.querySelector('.an'), stage, project, usable, reload })
else if (a.state === 'disabled') stage.innerHTML = '<section class="an-card an-state"><h3>Analyse ist abgeschaltet</h3><p>Für diese Website ist die Analyse im Moment gesperrt. <button type="button" class="kb-link" data-view="chat">Schreib uns im Chat</button>, dann klären wir das.</p></section>'
else if (a.state === 'unknown') stage.innerHTML = '<section class="an-card an-state"><p>Die Daten konnten gerade nicht geladen werden.</p></section>'
else if (!a.eligible) stage.innerHTML = `<section class="an-card an-state"><h3>Analyse nicht verfügbar</h3><p>${esc(a.reasonText || 'Für diese Website gerade nicht verfügbar.')}${a.reason === 'kein_abo' ? ` <button type="button" class="${ZU_VERTRAEGE.klasse}" data-view="${ZU_VERTRAEGE.view}">${ZU_VERTRAEGE.text}</button>` : ''}</p></section>`
else renderAnalyseActivation(stage, project, reload)
}
// Kopfleiste (analyse-cards.js); Zeitraum nur in der Uebersicht, "Stand" = Uhrzeit des letzten Ladens, today = "Heute bisher" aus der Statistik
function headHtml(project, usable, { showRange, stamp = '', today } = {}) {
const a = analyseOf(project)
return headerHtml({ project, usable, range: RANGES.includes(analyseSel.range) ? analyseSel.range : 30, showRange, devMode: a.state === 'active' && Boolean(a.devMode), stamp, today })
}
function bannerPreview(color) {
const c = bannerColor(color)
const btn = `style="background:${c};border-color:${c};color:#fff"`
return `
<div class="an-bn" role="img" aria-label="Vorschau des Banners auf deiner Website">
<p class="an-bn-text">${esc(BANNER_PREVIEW_TEXT)}<span class="an-bn-link">Datenschutz</span></p>
<div class="an-bn-row"><span class="an-bn-btn" ${btn}>Ablehnen</span><span class="an-bn-btn" ${btn}>Annehmen</span></div>
</div>`
}
const MISSING_PRIVACY_HINT = 'Für die Zusatzseite fehlen in Deinen Kundendaten Firma oder Name und eine gültige E-Mail-Adresse. Schreib uns im Chat, wir ergänzen sie.'
// Vorschau der Verantwortlichen-Angaben; sind sie unvollstaendig, bleibt "Analyse einschalten" gesperrt
function controllerBlock(controller) {
if (controller.ok !== true) return `<p class="error an-missing" id="an-missing" role="alert">${esc(MISSING_PRIVACY_HINT)}</p>`
return `<div class="an-ctl" id="an-ctl"><p>Auf der Datenschutz-Zusatzseite Deiner Website erscheinen als Verantwortlicher:</p><p><strong>${controllerLines(controller).map(esc).join(' · ')}</strong></p><p>Stimmt etwas nicht? Schreib uns im Chat, bevor Du einschaltest.</p></div>`
}
export function renderAnalyseActivation(stage, project, reload) {
const a = analyseOf(project)
const legal = a.legal || {}
const controller = a.controller || { ok: false }
let color = HEX_RE.test(a.accentColor || '') ? a.accentColor.toLowerCase() : '#111111'
stage.innerHTML = `
<div class="an-card an-panel">
<h3>Analyse für ${esc(project.projectName)} einschalten</h3>
<p>Du siehst, wie oft deine Website aufgerufen wird, woher die Besucher kommen und was sie sich ansehen.</p>
<p>Ohne Zustimmung der Besucher zählen wir nur Seitenaufrufe mit Land und Geräteklasse, ohne Kennung. Wer zustimmt, wird zusätzlich mit Herkunftsseite, Verweildauer, Scrolltiefe und Klicks gemessen. Wir speichern keine IP-Adressen und setzen keine Cookies. Die Messung läuft auf unserem Server bei Hetzner in Deutschland. Wer ablehnt, wird nicht weiter gemessen; gezählt wird nur, dass abgelehnt wurde.</p>
<h4 class="an-h4">Farbe des Banners</h4>
<div class="an-colors">
<input type="color" id="an-color" value="${esc(color)}" aria-label="Farbe wählen">
<div class="an-presets" role="group" aria-label="Vorgaben">${PRESET_COLORS.map((c) => `<button type="button" class="an-preset" data-color="${c}" style="background:${c}" aria-label="Farbe ${c}"></button>`).join('')}</div>
</div>
<p class="an-hint hidden" id="an-hint">Diese Farbe ist für weiße Schrift zu hell. Der Banner nutzt dann Schwarz (#111111).</p>
<div id="an-preview"></div>
${controllerBlock(controller)}
<label class="fn-consent"><input type="checkbox" id="an-avv"><span>Ich beauftrage WEBklar mit der Website-Analyse und stimme dem <a href="${esc(legal.avv || '')}" target="_blank" rel="noopener">Auftragsverarbeitungsvertrag Website-Analyse</a> und der <a href="${esc(legal.lb || '')}" target="_blank" rel="noopener">Leistungsbeschreibung Website-Analyse</a> (Fassung ${esc(a.avvVersion)}) zu. Meine Website richtet sich nicht gezielt an Kinder unter 16 Jahren.</span></label>
<p class="error hidden" id="an-error" role="alert"></p>
<div class="abo-actions"><button type="button" class="abo-btn" id="an-on" disabled>Analyse einschalten</button></div>
</div>`
const input = stage.querySelector('#an-color')
const hint = stage.querySelector('#an-hint')
const paint = () => {
stage.querySelector('#an-preview').innerHTML = bannerPreview(color)
hint.classList.toggle('hidden', bannerColor(color) === color.toLowerCase())
}
paint()
input.addEventListener('input', () => { color = input.value; paint() })
stage.querySelectorAll('.an-preset').forEach((b) => b.addEventListener('click', () => { color = b.dataset.color; input.value = color; paint() }))
const box = stage.querySelector('#an-avv')
const button = stage.querySelector('#an-on')
const error = stage.querySelector('#an-error')
box.addEventListener('change', () => { button.disabled = !box.checked || controller.ok !== true })
button.addEventListener('click', async () => {
if (!box.checked || controller.ok !== true) return
button.disabled = true
error.classList.add('hidden')
try {
await request('/api/funktionen/analyse/activate', { method: 'POST', body: { projectId: project.projectId, accentColor: color.toLowerCase(), consent: { accepted: true, avvVersion: a.avvVersion } } })
reload()
} catch (err) {
error.textContent = err.message
error.classList.remove('hidden')
button.disabled = !box.checked || controller.ok !== true
}
})
}
/* --- Aktiver Zustand: Reiter Uebersicht | Heatmap | Einstellungen ---
Zustand je Aufruf von initAnalyse in der Closure von mountCockpit; Ereignisse am Wurzelelement .an (Kopfleiste und Reiter werden
neu gezeichnet, ohne dass Handler neu gebunden werden muessen). statsRequestId verwirft veraltete Antworten, sobald etwas Neueres
angefordert oder der Reiter gewechselt wurde. */
let statsRequestId = 0
function hideChartTip() {
document.querySelectorAll('.an-dot.on').forEach((d) => d.classList.remove('on'))
document.querySelectorAll('.an-tip').forEach((t) => { t.hidden = true })
}
let tipListening = false
// Diagramm: Zeigen oder Tippen waehlt den naechsten Tag, die Blase zeigt "Sa., 19.09.: 371 Aufrufe (davor 210)"
function wireChart(stage) {
const svg = stage.querySelector('.an-chart')
const tip = stage.querySelector('.an-tip')
const wrap = svg?.parentElement
const dots = svg ? [...svg.querySelectorAll('.an-dot')] : []
if (!tip || !wrap || !dots.length) return
if (!tipListening) {
// Antippen ausserhalb schliesst die Blase (auf Touch bleibt sie sonst nach dem Loslassen stehen)
document.addEventListener('pointerdown', (ev) => { if (!ev.target?.closest?.('.an-chart')) hideChartTip() })
tipListening = true
}
const xs = dots.map((d) => Number(d.getAttribute('cx')))
const show = (ev) => {
const ctm = svg.getScreenCTM()
if (!ctm) return
const x = new DOMPoint(ev.clientX, ev.clientY).matrixTransform(ctm.inverse()).x
let best = 0
for (let i = 1; i < xs.length; i++) if (Math.abs(xs[i] - x) < Math.abs(xs[best] - x)) best = i
const dot = dots[best]
dots.forEach((d) => d.classList.toggle('on', d === dot))
tip.textContent = chartTipText(dot.dataset.day, dot.dataset.pv, dot.dataset.prev)
tip.hidden = false
const box = wrap.getBoundingClientRect()
const at = dot.getBoundingClientRect()
const half = tip.offsetWidth / 2
tip.style.left = `${Math.min(Math.max(at.left + at.width / 2 - box.left, half), box.width - half)}px`
tip.style.top = `${at.top + at.height / 2 - box.top}px`
tip.classList.toggle('below', at.top - box.top < tip.offsetHeight + 16)
}
svg.addEventListener('pointermove', show)
svg.addEventListener('pointerdown', show)
svg.addEventListener('pointerleave', (ev) => { if (ev.pointerType === 'mouse') hideChartTip() })
svg.addEventListener('pointercancel', hideChartTip)
}
function mountCockpit({ an, stage, project, usable, reload }) {
const pid = project.projectId
const state = { stats: null, range: 0, stamp: '', rank: { seen: {}, who: {} } }
const tabNow = () => (TABS.includes(analyseSel.tab) ? analyseSel.tab : 'uebersicht')
const rangeNow = () => (RANGES.includes(analyseSel.range) ? analyseSel.range : 30)
const current = (mine) => stage.isConnected && mine === statsRequestId
// Kopfleiste neu zeichnen (Zeitraum nur in der Uebersicht, Stand); Fokus auf einem Zeitraum-Knopf bleibt
function paintHead() {
const bar = an.querySelector('.an-top')
if (!bar) return
const focused = bar.contains(document.activeElement) ? document.activeElement.dataset.range : ''
bar.outerHTML = headHtml(project, usable, { showRange: tabNow() === 'uebersicht', stamp: state.stamp, today: state.stats?.today })
if (focused) an.querySelector(`.an-seg [data-range="${focused}"]`)?.focus()
}
// Reiterknoepfe in place umschalten (Fokus bleibt), nur der gewaehlte ist per Tab erreichbar, die anderen per Pfeiltaste
function paintTabs() {
const tab = tabNow()
an.querySelectorAll('.an-tabbtn').forEach((b) => {
const on = b.dataset.tab === tab
b.classList.toggle('on', on)
b.setAttribute('aria-selected', String(on))
b.tabIndex = on ? 0 : -1
})
stage.setAttribute('aria-labelledby', `an-tab-${tab}`)
}
// Anfrage je Zeitraum: Uebersicht und Heatmap teilen sich eine laufende Anfrage. Sie schreibt nichts in state; uebernommen
// (adopt) wird die Antwort erst nach current(mine), sonst ueberschriebe eine verspaetete den Stand des neueren Zeitraums.
const pending = {}
function fetchStats(range) {
if (!pending[range]) {
const p = request(`/api/funktionen/analyse/stats?projectId=${encodeURIComponent(pid)}&range=${range}`)
const done = () => { if (pending[range] === p) delete pending[range] }
p.then(done, done)
pending[range] = p
}
return pending[range]
}
function adopt(stats, range) {
state.stats = stats
state.range = range
state.stamp = CLOCK.format(new Date())
}
function paintOverview() {
stage.innerHTML = overviewHtml(state.stats)
for (const card of Object.keys(state.rank)) if (state.rank[card].tab) paintRank(card)
wireChart(stage)
}
// Rangliste einer Karte neu zeichnen (nur diese Karte); der Fokus kehrt auf den gewaehlten Umschalter zurueck
function paintRank(card, { focus = false } = {}) {
const old = stage.querySelector(`[data-rank="${card}"]`)
if (!old) return
old.outerHTML = rankCardHtml(card, state.stats, state.rank[card])
if (focus) stage.querySelector(`[data-rank="${card}"] .an-ltab.on`)?.focus()
}
async function showOverview() {
const mine = ++statsRequestId
const range = rangeNow()
if (state.stats && state.range === range) {
paintOverview()
return
}
stage.innerHTML = skeletonHtml()
let stats
try {
stats = await fetchStats(range)
} catch (err) {
if (!current(mine)) return
stage.innerHTML = errorHtml(err.status === 503 ? 'Auswertung gerade nicht erreichbar' : err.message)
return
}
if (!current(mine)) return
adopt(stats, range)
paintHead()
paintOverview()
}
// Heatmap: Seitenliste aus der zuletzt geladenen Statistik, sonst einmal 30 Tage laden; iframes erst jetzt
async function showHeat() {
const mine = ++statsRequestId
stage.innerHTML = '<div class="an-ov"><section class="an-card an-heat" id="an-heat"></section></div>'
const box = stage.querySelector('#an-heat')
if (!state.stats) {
box.innerHTML = '<h3>Heatmap</h3><p class="an-empty">Lade…</p>'
let stats
try {
stats = await fetchStats(30)
} catch (err) {
if (!current(mine)) return
stage.innerHTML = errorHtml(err.status === 503 ? 'Auswertung gerade nicht erreichbar' : err.message)
return
}
if (!current(mine)) return
adopt(stats, 30)
paintHead()
}
renderAnalyseHeat(box, pid, state.stats.pages || [])
}
function showSettings() {
statsRequestId += 1
stage.innerHTML = settingsHtml(project)
renderAnalyseOff(stage.querySelector('#an-off'), pid, reload)
}
function showTab() {
paintTabs()
paintHead()
const tab = tabNow()
if (tab === 'heatmap') showHeat()
else if (tab === 'einstellungen') showSettings()
else showOverview()
}
function pickTab(tab) {
if (!TABS.includes(tab) || tab === tabNow()) return
hideChartTip()
analyseSel.tab = tab
showTab()
}
an.addEventListener('click', (ev) => {
const btn = ev.target?.closest?.('button')
if (!btn || !an.contains(btn)) return
if (btn.dataset.range && btn.closest('.an-seg')) {
// Zeitraum: nur die Uebersicht laedt neu
const range = Number(btn.dataset.range)
if (!RANGES.includes(range) || range === rangeNow()) return
analyseSel.range = range
paintHead()
showOverview()
} else if (btn.dataset.tab && (btn.closest('.an-tabs') || btn.closest('.an-teaser'))) {
pickTab(btn.dataset.tab)
} else if (btn.id === 'an-retry') {
showTab()
} else if (btn.closest('.an-rank')) {
const card = btn.closest('.an-rank').dataset.rank
const rank = state.rank[card]
if (!rank) return
if (btn.dataset.rankTab) Object.assign(rank, { tab: btn.dataset.rankTab, expanded: false })
else if (btn.hasAttribute('data-rank-more')) rank.expanded = true
else return
paintRank(card, { focus: true })
}
})
// Pfeiltasten zwischen den Reitern (Reiter wechselt mit dem Fokus)
an.addEventListener('keydown', (ev) => {
if (!ev.target?.closest?.('.an-tabbtn')) return
const tabs = [...an.querySelectorAll('.an-tabbtn')]
const at = tabs.indexOf(ev.target.closest('.an-tabbtn'))
const next = { ArrowRight: (at + 1) % tabs.length, ArrowLeft: (at + tabs.length - 1) % tabs.length, Home: 0, End: tabs.length - 1 }[ev.key]
if (next === undefined) return
ev.preventDefault()
tabs[next].focus()
pickTab(tabs[next].dataset.tab)
})
showTab()
}
/* Heatmap: die echte Website in Geraeterahmen (gross: Computer oder Tablet, daneben das Handy), darueber die Klicks.
Die Website laeuft im iframe in echter Breite; h.js zeichnet dort das Overlay, sperrt Links und meldet die Klickzahl
per postMessage. Geraeterahmen: geraete.js + geraete.css (wird erst hier geladen). */
const heatSel = { big: 'd' }
const HEAT_TIMEOUT_MS = 20000
let geraeteCss = null
function loadGeraeteCss() {
if (!geraeteCss) {
geraeteCss = new Promise((resolve) => {
const link = document.createElement('link')
link.rel = 'stylesheet'
link.href = '/geraete.css'
link.onload = link.onerror = () => resolve()
document.head.appendChild(link)
})
}
return geraeteCss
}
// Nachricht von h.js aus einem der Rahmen: Klickzahl oder Fehler
let heatListening = false
function onHeatMessage(ev) {
const d = ev.data
if (!d || d.wk !== 'heat') return
for (const fig of document.querySelectorAll('.hs-dev')) {
const frame = fig.querySelector('iframe')
if (!frame || frame.contentWindow !== ev.source || ev.origin !== fig.dataset.origin) continue
clearTimeout(fig.heatTimer)
fig.dataset.state = d.state === 'ready' ? 'ready' : 'error'
setHeatCaption(fig, heatCaption(fig.dataset.state, Number(d.count) || 0, (n) => nf.format(n)))
}
}
function setHeatCaption(fig, text) {
const cap = fig.querySelector('.hs-cap')
if (cap) cap.textContent = text
}
// Bildschirmflaeche (dm-canvas) in Designgroesse per scale() einpassen, wie device-frames.js der Website
function fitHeatDevice(fig) {
fig.heatResize?.disconnect()
const canvas = fig.querySelector('.dm-canvas')
const host = canvas?.parentElement
if (!host) return
const fit = (width) => {
if (width > 0) canvas.style.setProperty('--dm-k', String(Math.round((width / Number(canvas.dataset.designWidth)) * 1e6) / 1e6))
}
fit(parseFloat(getComputedStyle(host).width) || host.clientWidth)
if (typeof ResizeObserver === 'function') {
fig.heatResize = new ResizeObserver((entries) => fit(entries[entries.length - 1].contentRect.width))
fig.heatResize.observe(host)
}
}
async function loadHeatDevice(fig, projectId, vp, path) {
const run = (fig.heatRun = (fig.heatRun || 0) + 1)
clearTimeout(fig.heatTimer)
fig.heatResize?.disconnect()
fig.dataset.state = 'loading'
fig.innerHTML = `<div class="hs-frame"></div><figcaption class="hs-cap" aria-live="polite">${esc(heatCaption('loading'))}</figcaption>`
let url
try {
;({ url } = await request('/api/funktionen/analyse/heatmap-link', { method: 'POST', body: { projectId, path, vp, embed: true } }))
} catch (err) {
if (run === fig.heatRun) setHeatCaption(fig, err.status === 503 ? 'Auswertung gerade nicht erreichbar' : err.message)
return
}
await loadGeraeteCss()
if (run !== fig.heatRun || !fig.isConnected) return
if (!safeUrl(url)) return setHeatCaption(fig, heatCaption('error'))
fig.dataset.origin = new URL(url).origin
fig.querySelector('.hs-frame').innerHTML = deviceHtml(vp, { src: url, title: `Heatmap ${HEAT_DEVICES[vp].label}, Seite ${path}` })
fitHeatDevice(fig)
// Seitenwechsel im Rahmen (Links sperrt h.js, Skripte der Seite koennten trotzdem wechseln): Heatmap neu laden
let loads = 0
fig.querySelector('iframe').addEventListener('load', () => {
if (++loads > 1 && run === fig.heatRun && (fig.heatReloads = (fig.heatReloads || 0) + 1) <= 3) loadHeatDevice(fig, projectId, vp, path)
})
fig.heatTimer = setTimeout(() => {
if (run === fig.heatRun && fig.isConnected && fig.dataset.state === 'loading') showHeatFallback(fig, projectId, vp, path)
}, HEAT_TIMEOUT_MS)
}
// Website laesst sich nicht einbetten (oder das Analyse-Skript fehlt): Heatmap im eigenen Tab wie bisher
async function showHeatFallback(fig, projectId, vp, path) {
fig.dataset.state = 'timeout'
const cap = fig.querySelector('.hs-cap')
if (!cap) return
cap.textContent = heatCaption('timeout')
try {
const { url } = await request('/api/funktionen/analyse/heatmap-link', { method: 'POST', body: { projectId, path, vp } })
// Kein window.open nach await (Safari/iOS blockiert das): sichtbarer Link, den der Kunde selbst antippt
if (safeUrl(url) && cap.isConnected) cap.insertAdjacentHTML('beforeend', ` <a class="kb-link" href="${esc(url)}" target="_blank" rel="noopener">In neuem Tab öffnen</a>`)
} catch {
/* Text reicht */
}
}
function renderAnalyseHeat(box, projectId, pages) {
if (!box) return
if (!pages.length) {
box.innerHTML = '<h3>Heatmap</h3><p class="an-empty">Sobald Besucher zugestimmt haben und Seiten aufgerufen wurden, kannst du hier sehen, wo geklickt und gescrollt wird.</p>'
return
}
if (!heatListening) {
window.addEventListener('message', onHeatMessage)
heatListening = true
}
const big = heatSel.big === 't' ? 't' : 'd'
box.innerHTML = `
<div class="hs-head"><h3>Heatmap</h3></div>
<p>So sieht deine Website auf den Geräten aus, mit den Klicks der letzten 30 Tage. Scrolle in den Geräten durch die Seite; Links sind hier gesperrt.</p>
<div class="an-heat-bar">
<label>Seite<select id="an-heat-page">${pages.map((p) => `<option value="${esc(p.key)}">${esc(p.key || '/')}</option>`).join('')}</select></label>
<div class="an-seg hs-switch" role="group" aria-label="Großes Gerät">${['d', 't'].map((vp) => `<button type="button"${vp === big ? ' class="on"' : ''} data-vp="${vp}" aria-pressed="${vp === big}">${HEAT_DEVICES[vp].label}</button>`).join('')}</div>
</div>
<div class="hs-stage" data-big="${big}">
<figure class="hs-dev hs-dev--big"></figure>
<figure class="hs-dev hs-dev--phone"></figure>
</div>`
const stage = box.querySelector('.hs-stage')
const bigFig = box.querySelector('.hs-dev--big')
const phoneFig = box.querySelector('.hs-dev--phone')
const page = () => box.querySelector('#an-heat-page').value || '/'
const show = (fig, vp) => {
fig.heatReloads = 0
loadHeatDevice(fig, projectId, vp, page())
}
box.querySelector('#an-heat-page').addEventListener('change', () => {
show(bigFig, heatSel.big)
show(phoneFig, 'm')
})
box.querySelectorAll('.hs-switch button').forEach((b) => b.addEventListener('click', () => {
if (b.dataset.vp === heatSel.big) return
heatSel.big = b.dataset.vp
box.querySelectorAll('.hs-switch button').forEach((x) => {
const on = x.dataset.vp === heatSel.big
x.classList.toggle('on', on)
x.setAttribute('aria-pressed', String(on))
})
stage.dataset.big = heatSel.big
show(bigFig, heatSel.big)
}))
heatSel.big = big
// Websites erst laden, wenn die Heatmap in Sicht kommt (sie steht unten auf der Seite)
const start = () => {
show(bigFig, heatSel.big)
show(phoneFig, 'm')
}
if (typeof IntersectionObserver !== 'function') return start()
const io = new IntersectionObserver((entries) => {
if (!entries.some((e) => e.isIntersecting)) return
io.disconnect()
start()
}, { rootMargin: '300px 0px' })
io.observe(stage)
}
// Ausschalten mit Bestaetigung in der Seite (zweiter Klick), ohne Browser-Dialog
export function renderAnalyseOff(box, projectId, reload) {
box.innerHTML = `
<h3>Analyse ausschalten</h3>
<p>Danach werden keine neuen Daten mehr gespeichert, und das Banner erscheint nicht mehr. Skript und Zusatzseite entfernen wir mit der nächsten Veröffentlichung Deiner Website, in der Regel sofort. Die bisherigen Daten löschen wir nach 60 Tagen; bis zum 46. Tag kannst Du eine Kopie anfordern.</p>
<p class="error hidden" id="an-off-error" role="alert"></p>
<div class="an-confirm" id="an-off-actions"><button type="button" class="an-ghost" id="an-off-ask">Analyse ausschalten</button></div>`
const actions = box.querySelector('#an-off-actions')
const error = box.querySelector('#an-off-error')
box.querySelector('#an-off-ask').addEventListener('click', () => {
actions.innerHTML = '<span>Wirklich ausschalten?</span> <button type="button" class="an-ghost an-danger" id="an-off-yes">Ja, ausschalten</button> <button type="button" class="an-ghost" id="an-off-no">Abbrechen</button>'
actions.querySelector('#an-off-no').addEventListener('click', () => renderAnalyseOff(box, projectId, reload))
actions.querySelector('#an-off-yes').addEventListener('click', async (ev) => {
ev.currentTarget.disabled = true
try {
await request('/api/funktionen/analyse/deactivate', { method: 'POST', body: { projectId } })
box.innerHTML = `
<h3>Analyse ausgeschaltet</h3>
<p id="an-off-done">Die Website-Analyse ist ausgeschaltet. Ab jetzt werden keine Daten mehr gespeichert.</p>
<div class="abo-actions"><button type="button" class="abo-btn" id="an-off-ok">OK</button></div>`
box.querySelector('#an-off-ok').addEventListener('click', () => reload())
} catch (err) {
renderAnalyseOff(box, projectId, reload)
const e2 = box.querySelector('#an-off-error')
e2.textContent = err.message
e2.classList.remove('hidden')
}
})
})
}
+5 -4
View File
@@ -139,7 +139,7 @@ function projectState(project) {
return ['kb-state-off', 'Noch keine Website']
}
const FEATURE_LABELS = { preview: 'Vorschau', chat: 'Chat', invoices: 'Rechnungen', news: 'News-Pinnwand', termine: 'Terminplaner', zeiten: 'Öffnungszeiten' }
const FEATURE_LABELS = { preview: 'Vorschau', chat: 'Chat', invoices: 'Rechnungen', news: 'News-Pinnwand', termine: 'Terminplaner', shop: 'Webshop', zeiten: 'Öffnungszeiten' }
function renderProjectCard(project, features) {
const li = document.createElement('li')
@@ -1910,15 +1910,16 @@ function initPasswordView() {
// "projekt" (Projektdetails als eigene Seite) gibt es ebenfalls nur in der App-Ansicht.
// "passwort" oeffnet das Konto-Menue (Profilbild). #fortschritt fuehrt zu Projekte und scrollt zum Abschnitt
// "Was wir zuletzt erledigt haben" (#pj-activity); hinter dem Hash darf ein Query stehen (#news?bestellt=1, Rueckkehr von Stripe).
const VIEW_HASHES = { projects: '', funktionen: 'funktionen', news: 'news', analyse: 'analyse', termine: 'termine', zeiten: 'zeiten', invoices: 'rechnungen', chat: 'chat', vertraege: 'vertraege', stunden: 'stunden', 'bestellen-hosting': 'bestellen-hosting', 'bestellen-wartung': 'bestellen-wartung', wechsel: 'wechsel', takeover: 'gutschrift', projekt: 'projekt', passwort: 'passwort' }
const VIEW_HASHES = { projects: '', funktionen: 'funktionen', news: 'news', analyse: 'analyse', termine: 'termine', invoices: 'rechnungen', chat: 'chat', vertraege: 'vertraege', stunden: 'stunden', 'bestellen-hosting': 'bestellen-hosting', 'bestellen-wartung': 'bestellen-wartung', wechsel: 'wechsel', takeover: 'gutschrift', projekt: 'projekt', passwort: 'passwort', webshop: 'webshop', zeiten: 'zeiten' }
const HASH_ALIASES = { wartung: 'vertraege', abo: 'vertraege' }
const viewLoaders = { chat: initChat }
// Funktionen und News laden bei jedem Oeffnen neu (Zustand aendert sich durch Bestellung und Freischaltung); Modul erst dann laden
const viewRefreshers = {
funktionen: () => import('/funktionen.js').then((m) => m.initFunktionen(document.getElementById('funktionen-body'))),
news: () => import('/funktionen.js').then((m) => m.initNews(document.getElementById('news-body'))),
analyse: () => import('/funktionen.js').then((m) => m.initAnalyse(document.getElementById('analyse-body'))),
analyse: () => import('/analyse.js').then((m) => m.initAnalyse(document.getElementById('analyse-body'))),
termine: () => import('/termine.js').then((m) => m.initTermine(document.getElementById('termine-body'))),
webshop: () => import('/shop.js').then((m) => m.initShop(document.getElementById('webshop-body'))),
// baut sich bei gleichem Projekt und Stand nicht neu auf, solange der Editor steht (ungespeicherte Eingaben bleiben; zeiten.js, initZeiten)
zeiten: () => import('/zeiten.js').then((m) => m.initZeiten(document.getElementById('zeiten-body'))),
}
@@ -2113,7 +2114,7 @@ const appLayout = window.matchMedia('(max-width: 880px), (pointer: coarse) and (
const coarsePointer = window.matchMedia('(pointer: coarse)')
// Unterseite -> Tab, zu dem sie gehoert (und Text neben dem Zurueck-Pfeil).
// "passwort" setzt onAppViewShown: die Ansicht, aus der das Konto-Menue geoeffnet wurde.
const APP_PARENT = { news: 'funktionen', analyse: 'funktionen', termine: 'funktionen', zeiten: 'funktionen', stunden: 'vertraege', 'bestellen-hosting': 'vertraege', 'bestellen-wartung': 'vertraege', wechsel: 'vertraege', takeover: 'vertraege', projekt: 'projects' }
const APP_PARENT = { news: 'funktionen', analyse: 'funktionen', termine: 'funktionen', stunden: 'vertraege', 'bestellen-hosting': 'vertraege', 'bestellen-wartung': 'vertraege', wechsel: 'vertraege', takeover: 'vertraege', projekt: 'projects', webshop: 'funktionen', zeiten: 'funktionen' }
const APP_PARENT_LABEL = { vertraege: 'Verträge', projects: 'Projekte', funktionen: 'Funktionen', invoices: 'Rechnungen', chat: 'Chat' }
const appShell = { view: 'projects', scroll: {}, fromHistory: false, sideCards: [], installPrompt: null }
const TEXT_INPUT = 'textarea, input:not([type=checkbox]):not([type=radio]):not([type=file]):not([type=date]):not([type=button]):not([type=submit]):not([type=range]):not([type=color])'
+12 -1
View File
@@ -22,7 +22,9 @@
<link rel="stylesheet" href="/kundenbereich.css">
<link rel="stylesheet" href="/chat-composer.css">
<link rel="stylesheet" href="/funktionen.css">
<link rel="stylesheet" href="/analyse.css">
<link rel="stylesheet" href="/termine.css">
<link rel="stylesheet" href="/shop.css">
<link rel="stylesheet" href="/zeiten.css">
</head>
<body class="portal-page dashboard-page kb-page">
@@ -178,7 +180,7 @@
<div id="news-body"><p class="kb-empty">Lade…</p></div>
</section>
<!-- Analyse: Einschalten oder Auswertung (funktionen.js, initAnalyse) -->
<!-- Analyse: Einschalten oder Auswertung (analyse.js, initAnalyse) -->
<section class="kb-view" id="view-analyse">
<header class="kb-head">
<h1>Analyse</h1>
@@ -196,6 +198,15 @@
<div id="termine-body"><p class="kb-empty">Lade…</p></div>
</section>
<!-- Webshop: Anfrage, Bestellung, Artikel, Bestellungen, Einstellungen (shop.js, initShop); Unterseite von Funktionen -->
<section class="kb-view" id="view-webshop">
<header class="kb-head">
<h1>Webshop</h1>
<p>Dein eigener Online-Shop auf deiner Website – Zahlungen gehen direkt auf dein Stripe-Konto.</p>
</header>
<div id="webshop-body"><p class="kb-empty">Lade…</p></div>
</section>
<!-- Öffnungszeiten live: Einschalten oder Bestellen, Status und Editor (zeiten.js, initZeiten); Unterseite von Funktionen. Kein aria-live am Bereich -->
<section class="kb-view" id="view-zeiten">
<header class="kb-head">
+9 -3
View File
@@ -100,12 +100,18 @@
<p>Der Kunde sieht, ändert und löscht die Termine im Kundenportal. Dort verarbeiten wir als Verantwortliche seine Einstellungen für den Terminplaner (Betriebsangaben, Leistungen, Namen und Zeiten der gebuchten Personen oder Plätze, Öffnungszeiten, Absagefristen und Texte), wie die übrigen Portal-Daten (Abschnitt 6) auf Grundlage von Art. 6 Abs. 1 lit. b DSGVO. Bevor der Kunde den Terminplaner bestellt, füllt er im Kundenportal einen Fragebogen aus (Branche und Betrieb, Personen oder Plätze, Leistungen, Zeiten, Regeln, Angaben von Gästen, Benachrichtigungen und Website); seine Eingaben speichern wir als Entwurf seiner Einstellungen. Bestellt er nicht, löschen wir den Entwurf 90 Tage nach der letzten Änderung, bei der Löschung seines Kundenkontos zugleich mit dem Konto. Die Bestellung und ihren Nachweis beschreibt Abschnitt 7. Für jede Person oder jeden Platz kann der Kunde einen geheimen Link erzeugen, mit dem er die bestätigten Termine in seinem Kalenderprogramm abonniert; wer den Link kennt, sieht diese Termine. Der Kunde kann den Link jederzeit durch einen neuen ersetzen.</p>
<p>Im Namen des Kunden senden wir E-Mails an Gäste (Bestätigung der E-Mail-Adresse, Bestätigung, Änderung oder Absage eines Termins, Eingangsbestätigung eines Widerrufs und, wenn der Gast das gewählt hat, eine Erinnerung) und an den Kunden (neue Buchung, Absage, Widerruf). Der Versand läuft über den E-Mail-Dienst der Hetzner Online GmbH (Rechenzentrum Nürnberg, Abschnitt 8); als Absender erscheint der Kunde, Antworten gehen an ihn. Wir verschicken keine Werbung, setzen keine Tracking-Pixel ein und erhalten keine Kopie. Bis zum Versand liegt jede E-Mail in einer Warteschlange. Nach dem Versand löschen wir dort Empfängeradresse und Inhalt, den Eintrag selbst nach sieben Tagen; E-Mails, die nicht zugestellt werden konnten, löschen wir nach 48 Stunden.</p>
<p>Beim Aufruf einer Kundenwebsite mit Terminbuchung lädt der Browser das Programm der Terminbuchung von unserem Server (project.webklar.com) und schon beim Laden der Seite die Konfiguration mit den Texten und Leistungen. Ist die Terminbuchung unmittelbar in die Seite eingebaut, holt sie die freien Termine, sobald der Gast durchklickt; sitzt sie hinter einem Knopf, geschieht das erst nach dem Klick auf den Knopf. Dabei gelten die Protokolle in Abschnitt 3. Die Terminbuchung setzt keine Cookies und speichert nichts im Browser des Gastes. Zum Schutz vor Missbrauch und um die Zahl der Anfragen zu begrenzen (höchstens zehn Buchungen je zehn Minuten und je IP-Adresse), merken wir uns die Zeitpunkte der Aufrufe je IP-Adresse nur im Arbeitsspeicher des Servers, nicht dauerhaft; mit der Buchung speichern wir die IP-Adresse nicht. Rechtsgrundlage ist Art. 6 Abs. 1 lit. f DSGVO; unser berechtigtes Interesse ist der sichere und störungsfreie Betrieb.</p>
<p>Löschfristen: Nicht bestätigte Buchungen löschen wir 48 Stunden nach Ablauf der Bestätigungsfrist von 30 Minuten, abgesagte oder abgelehnte Termine nach 30 Tagen, bestätigte Termine nach der Frist, die der Kunde eingestellt hat (Vorgabe 90 Tage, einstellbar von 14 bis 365 Tagen nach dem Termin). Nicht bestellte Entwürfe löschen wir 90 Tage nach der letzten Änderung. Endet das Wartungs-Abo oder schalten wir die Funktion ab, schalten wir den Terminplaner spätestens am folgenden Tag ab und löschen alle Termindaten und Einstellungen 30 Tage danach; beginnt innerhalb dieser Frist wieder ein Wartungs-Abo, schalten wir ihn mit den vorhandenen Daten wieder ein. In unseren Datensicherungen bleiben gelöschte Daten noch bis zu sieben Tage erhalten und werden dann überschrieben. Die Daten liegen auf unserem Server bei Hetzner im Rechenzentrum Falkenstein (Deutschland), wie die übrigen Portal-Daten (Abschnitt 3).</p>
<p>Löschfristen: Nicht bestätigte Buchungen löschen wir 48 Stunden nach Ablauf der Bestätigungsfrist von 30 Minuten, abgesagte oder abgelehnte Termine nach 30 Tagen, bestätigte Termine nach der Frist, die der Kunde eingestellt hat (Vorgabe 90 Tage, einstellbar von 14 bis 365 Tagen nach dem Termin). Nicht bestellte Entwürfe löschen wir 90 Tage nach der letzten Änderung. Endet das Wartungs-Abo oder schalten wir die Funktion ab, schalten wir den Terminplaner spätestens am folgenden Tag ab und löschen alle Termindaten und Einstellungen 30 Tage danach; beginnt innerhalb dieser Frist wieder ein Wartungs-Abo, schalten wir ihn mit den vorhandenen Daten wieder ein. Eine Ausnahme ist das Einrichtungs-Ticket, in dem wir die Einrichtung als Arbeitsnachweis festhalten (Abschnitt 6): Es bleibt mit einer Kurzfassung der Einstellungen bestehen und enthält keine Daten von Gästen. In unseren Datensicherungen bleiben gelöschte Daten noch bis zu sieben Tage erhalten und werden dann überschrieben. Die Daten liegen auf unserem Server bei Hetzner im Rechenzentrum Falkenstein (Deutschland), wie die übrigen Portal-Daten (Abschnitt 3).</p>
<h2 id="webshop">6d. Webshop auf Websites unserer Kunden</h2>
<p>Kunden können auf ihrer eigenen Website einen Webshop betreiben („Webshop“), sobald wir die Funktion für sie freigeschaltet haben. Dabei verarbeiten wir die Daten der Personen, die dort bestellen oder einen Vertrag widerrufen („Käufer“), ausschließlich als Auftragsverarbeiter nach den Weisungen des jeweiligen Kunden, der dafür Verantwortlicher ist (Auftragsverarbeitungsvertrag Webshop). Wir verwenden diese Daten nicht für eigene Zwecke. Verantwortliche sind wir für die Betriebsdaten des Kunden im Kundenportal und für seine Bestellung der Funktion. Diese Datenschutzerklärung gilt für unsere eigenen Seiten und das Kundenportal. Wer auf der Website eines Kunden bestellt, findet die Informationen zur Bestellung (Zwecke, Rechtsgrundlagen, Speicherdauer, Rechte) in der Datenschutzerklärung dieser Website. Anfragen von Käufern zu ihren Daten leiten wir an den Kunden weiter.</p>
<p>Für den Betrieb des Webshops speichern wir im Auftrag des Kunden: Name, E-Mail-Adresse und Liefer- und Rechnungsanschrift des Käufers (eine Telefonnummer erheben und speichern wir nicht), die bestellten Artikel mit Preisen, Versandart und Gesamtbetrag, den Stand der Bestellung (zum Beispiel bezahlt, versendet oder erstattet) mit den Zeitpunkten, die Prüfsumme der Rechtstexte, die der Käufer bei der Bestellung gesehen hat, bis zur Zahlung einen Hashwert seiner IP-Adresse (siehe unten) und die Angaben eines Widerrufs (Name, E-Mail-Adresse, Bestellnummer, Erklärung, Zeitpunkt des Eingangs). Zahlungsdaten wie Kartennummer oder IBAN gibt der Käufer nur auf der Zahlungsseite von Stripe ein; wir erhalten sie nicht. Der Kunde sieht und bearbeitet Bestellungen und Widerrufe im Kundenportal. Im Namen des Kunden senden wir E-Mails an Käufer (Eingang der Bestellung bei ausstehender Zahlung, Bestellbestätigung, Versand, Abholbereitschaft, Eingangsbestätigung eines Widerrufs, Erstattung, fehlgeschlagene Zahlung) und an den Kunden (neue Bestellung, Widerruf) über den E-Mail-Dienst der Hetzner Online GmbH (Rechenzentrum Nürnberg, Abschnitt 8). Der Warenkorb liegt nur im Sitzungsspeicher des Browsers des Besuchers (Artikelkennungen und Mengen, kein Cookie); er ist technisch erforderlich und wird beim Schließen des Tabs gelöscht. Zum Schutz vor Missbrauch begrenzen wir die Zahl der Bestellungen und Widerrufe je IP-Adresse; die Zeitpunkte der Aufrufe merken wir uns nur im Arbeitsspeicher des Servers, nicht dauerhaft. Außerdem lassen wir je IP-Adresse höchstens drei offene, noch nicht bezahlte Bestellungen innerhalb von 30 Minuten zu. Dafür speichern wir mit der Bestellung nicht die IP-Adresse, sondern einen Hashwert davon, der mit einem geheimen Schlüssel unseres Servers berechnet wird (Pseudonym); ohne den Schlüssel lässt sich daraus keine IP-Adresse ableiten. Wir verwenden ihn nur für diese Begrenzung und löschen ihn, sobald die Bestellung bezahlt oder storniert wird, spätestens mit der Löschung nicht bezahlter Bestellungen nach 30 Tagen. Mit dem Widerruf speichern wir keine IP-Adresse. Die Rechtstexte, die der Kunde für den Webshop bestätigt, legen wir mit Prüfsumme, Zeitpunkt und bestätigender Person im Archiv ab, damit die Bestellbestätigung die Fassung zeigt, die bei der Bestellung galt. Im Übrigen gelten die Protokolle in Abschnitt 3.</p>
<p>Zahlungen im Webshop laufen über Stripe Connect auf das eigene Stripe-Konto des Kunden; wir erhalten und verwahren kein Geld der Käufer. Um dieses Konto zu eröffnen, übermitteln wir, sobald der Kunde im Kundenportal die Verbindung mit Stripe auslöst, seine E-Mail-Adresse, die Adresse seiner Website und den Namen seines Projekts an die Stripe Payments Europe, Limited, 1 Grand Canal Street Lower, Grand Canal Dock, Dublin, D02 H210, Irland („Stripe“), zusammen mit internen Kennzeichen, mit denen wir das Konto seinem Projekt zuordnen. Die weiteren Angaben für das Konto (zum Beispiel zur Person und zum Unternehmen) gibt der Kunde direkt bei Stripe ein. Wir speichern die Kennung des Stripe-Kontos und seinen Stand (ob Zahlungen und Auszahlungen möglich sind), den uns Stripe meldet, und zeigen ihn dem Kunden im Kundenportal an. Rechtsgrundlage ist Art. 6 Abs. 1 lit. b DSGVO (Vertrag mit dem Kunden). Stripe verarbeitet die Daten für sein Konto als eigener Verantwortlicher; dabei können Daten an die Stripe, LLC in den USA übermittelt werden, die nach dem EU-US Data Privacy Framework zertifiziert ist (Abschnitt 7). Es gelten die Datenschutzhinweise von Stripe: stripe.com/de/privacy.</p>
<p>Löschfristen: Nicht bezahlte und stornierte Bestellungen löschen wir nach 30 Tagen. Bei bezahlten Bestellungen und Widerrufen anonymisieren wir Name, E-Mail-Adresse und Anschriften des Käufers drei Jahre nach dem Ende des Jahres der Bestellung; Positionen, Beträge, Bestellnummer und Status bleiben für die Unterlagen des Kunden. Schaltet der Kunde den Webshop ab oder endet der Vertrag über den Betrieb seiner Website, schalten wir den Webshop ab. Der Kunde kann Bestellungen und Widerrufe danach 90 Tage lang im Kundenportal lesen und exportieren; danach löschen wir alle Daten des Webshops (Artikel, Bilder, Bestellungen, Widerrufe, Einstellungen und das Archiv der bestätigten Rechtstexte). Beginnt innerhalb dieser Frist wieder ein Vertrag über den Betrieb der Website, schalten wir den Webshop auf Wunsch des Kunden mit den vorhandenen Daten wieder ein. Eine Ausnahme ist das Einrichtungs-Ticket, in dem wir die Einrichtung als Arbeitsnachweis festhalten (Abschnitt 6): Es bleibt mit einer Kurzfassung der Angaben zur Einrichtung bestehen und enthält keine Daten von Käufern. Löschen wir Daten oder anonymisieren wir sie, bleiben sie in unseren Datensicherungen noch bis zu sieben Tage erhalten und werden dann überschrieben. Die Daten liegen auf unserem Server bei Hetzner im Rechenzentrum Falkenstein (Deutschland), wie die übrigen Portal-Daten (Abschnitt 3). Die Bestellung der Funktion und ihren Nachweis beschreibt Abschnitt 7.</p>
<h2>7. Abos und Zahlung über Stripe</h2>
<p>Schließen Sie eines unserer Abos ab, verarbeiten wir Name, E-Mail-Adresse, Rechnungsanschrift, das gewählte Abo und den gebuchten Zusatzspeicher, Abo- und Zahlungsstatus sowie Ihre Rechnungen. Zahlungsdaten wie Kartennummer oder IBAN geben Sie direkt bei unserem Zahlungsdienstleister ein. Wir erhalten davon nur eingeschränkte Angaben, etwa die Art des Zahlungsmittels und die letzten vier Ziffern.</p>
<p>Um Ihre Bestellungen nachweisen zu können, speichern wir bei jeder Bestellung Zeitpunkt, Version der Vertragsbedingungen, gegebenenfalls Ihre Angaben zum Leistungsbeginn sowie IP-Adresse und Browserkennung, im Kundenportal und als Angaben zum Abo bei Stripe. Das gilt für den Abschluss eines Abos, für den Wechsel vom Hosting-Abo ins Wartungs-Abo und für jede Bestellung von E-Mail-Zusatzspeicher. Außerdem halten wir Ihre Bestellungen und Änderungen als Vertragsereignisse mit Zeitpunkt und Status fest: Abschluss, Wechsel, Zusatzspeicher einschließlich Verringerungen, Kündigung und Widerruf. Bei Verringerungen, Kündigung und Widerruf speichern wir als Vertragsereignis nur Zeitpunkt und Status, keine IP-Adresse und Browserkennung. Den Stand Ihrer Bestellungen, etwa laufende Widerrufsfristen und die Einrichtung von Zusatzspeicher, sehen Sie im Kundenportal unter „Verträge“. Für uns sind die Vertragsereignisse zugleich die Aufgabenliste, um Änderungen am Zusatzspeicher umzusetzen. Rechtsgrundlagen sind Art. 6 Abs. 1 lit. b, c und f DSGVO. Unser berechtigtes Interesse ist der Nachweis des Vertragsschlusses und Ihrer Bestellungen. Diese Angaben löschen wir drei Jahre nach Ablauf des Jahres, in dem Ihr letztes Abo endet.</p>
<p>Bestellen Sie eine Funktion (News-Pinnwand, Terminplaner), speichern wir als Nachweis die Texte und Fassungen, denen Sie zugestimmt haben, den Zeitpunkt Ihres Klicks auf „Zahlungspflichtig bestellen“, die Stripe-Sitzung, IP-Adresse und Browserkennung sowie Ihr Konto und die angemeldete Person. Rechtsgrundlagen sind Art. 6 Abs. 1 lit. b, c und f DSGVO; unser berechtigtes Interesse ist der Nachweis des Vertragsschlusses und der Zustimmungen, insbesondere für den Widerruf. Diese Angaben löschen wir drei Jahre nach Ablauf des Jahres, in dem Ihr letztes Abo endet. Den Entwurf Ihres Fragebogens speichern wir bis zu 90 Tage nach Ihrer letzten Änderung und löschen ihn danach, wenn Sie nichts bestellt haben.</p>
<p>Bestellen Sie eine Funktion (News-Pinnwand, Terminplaner), speichern wir als Nachweis die Texte und Fassungen, denen Sie zugestimmt haben, den Zeitpunkt Ihres Klicks auf „Zahlungspflichtig bestellen“, die Stripe-Sitzung, IP-Adresse und Browserkennung sowie Ihr Konto und die angemeldete Person. Rechtsgrundlagen sind Art. 6 Abs. 1 lit. b, c und f DSGVO; unser berechtigtes Interesse ist der Nachweis des Vertragsschlusses und der Zustimmungen, insbesondere für den Widerruf. Diese Angaben löschen wir drei Jahre nach Ablauf des Jahres, in dem Ihr letztes Abo endet. Bestellen Sie ohne Abo (zum Beispiel nur die News-Pinnwand), löschen wir diese Angaben spätestens drei Jahre nach Ablauf des Jahres der Bestellung. Den Entwurf Ihres Fragebogens speichern wir bis zu 90 Tage nach Ihrer letzten Änderung und löschen ihn danach, wenn Sie nichts bestellt haben.</p>
<p>Die Zahlungen wickelt die Stripe Payments Europe, Limited, 1 Grand Canal Street Lower, Grand Canal Dock, Dublin, D02 H210, Irland („Stripe“) ab. Stripe verarbeitet die Daten teils in unserem Auftrag und teils als eigener Verantwortlicher, etwa zur Betrugsvorbeugung und zur Erfüllung gesetzlicher Pflichten. Dabei können Daten an die Stripe, LLC in den USA übermittelt werden. Die Stripe, LLC ist nach dem EU-US Data Privacy Framework zertifiziert, für das ein Angemessenheitsbeschluss der EU-Kommission besteht. Auf der Zahlungsseite von Stripe gelten dessen Datenschutzhinweise: stripe.com/de/privacy. Rechtsgrundlage ist Art. 6 Abs. 1 lit. b DSGVO.</p>
<p id="uebernahmegutschrift">Beantragen Sie im Kundenportal eine Übernahmegutschrift, verarbeiten wir Ihre Angaben zum bisherigen Anbieter (Anbieter, Leistung, Domain, Rechnungsbetrag, bezahlter Zeitraum), Ihre Nachricht, die hochgeladenen Nachweise wie Rechnung oder Kündigungsbestätigung, die E-Mail-Adresse für unsere Antwort sowie Zeitpunkt und IP-Adresse Ihrer Bestätigung. Die Nachweise speichern wir auf unserem Server; unsere Mitarbeiter erhalten per E-Mail nur einen Hinweis auf den Antrag. Zur Prüfung fragen wir die öffentlichen DNS-Einträge der angegebenen Domain ab (Nameserver, Mailserver, IP-Adresse). Eine genehmigte Gutschrift buchen wir bei Stripe als Guthaben auf Ihr Kundenkonto. Rechtsgrundlage ist Art. 6 Abs. 1 lit. b DSGVO, für den Nachweis Ihrer Bestätigung Art. 6 Abs. 1 lit. f DSGVO. Nachweise zu abgelehnten Anträgen löschen wir sechs Monate nach der Entscheidung. Bei genehmigten Anträgen sind Antrag und Nachweise Beleg für die Gutschrift und werden wie in Abschnitt 9 beschrieben aufbewahrt.</p>
<p id="kuendigung-widerruf">Über die Seiten project.webklar.com/kuendigen.html und project.webklar.com/widerrufen.html können Sie Ihren Vertrag ohne Anmeldung kündigen oder widerrufen (§§ 312k, 356a BGB). Dabei verarbeiten wir Ihren Namen, Ihre E-Mail-Adresse, den gewählten Vertrag, beim E-Mail-Zusatzspeicher die Zahl der Pakete, die Sie kündigen, gegebenenfalls Ihre Kundennummer, bei der Kündigung außerdem Art und gewünschten Zeitpunkt der Kündigung und gegebenenfalls Ihre Begründung, sowie den Zeitpunkt, zu dem Ihre Erklärung bei uns eingegangen ist. Sind Sie im Kundenportal angemeldet, füllen wir Name, E-Mail-Adresse und Kundennummer aus Ihrem Konto vor. Wir nutzen die Angaben, um Ihre Erklärung zu bearbeiten: Anhand der E-Mail-Adresse ordnen wir sie Ihrem Abo zu, vermerken den Eingang beim Abo und tragen die Kündigung oder den Widerruf, soweit möglich, bei Stripe ein. Sie erhalten sofort eine Bestätigung des Eingangs mit Inhalt, Datum und Uhrzeit Ihrer Erklärung an die angegebene E-Mail-Adresse; unser Team erhält eine E-Mail mit diesen Angaben. Rechtsgrundlage ist Art. 6 Abs. 1 lit. c DSGVO (§§ 312k, 356a BGB) in Verbindung mit Art. 6 Abs. 1 lit. b DSGVO. Die IP-Adresse Ihres Aufrufs nutzen wir nur, um die Zahl der Anfragen zu begrenzen (höchstens zehn pro Stunde je IP-Adresse). Dafür merken wir uns die Zeitpunkte der Aufrufe je IP-Adresse nur im Arbeitsspeicher des Servers, nicht dauerhaft; mit Ihrer Erklärung speichern wir die IP-Adresse nicht. Im Übrigen gilt für den Aufruf Abschnitt 3. Ihre Erklärung und unsere Bestätigung bewahren wir wie die übrigen Vertragsdaten drei Jahre nach Ablauf des Jahres, in dem Ihr letztes Abo endet auf; soweit die E-Mails Geschäftsbriefe sind, gilt die Frist aus Abschnitt 9.</p>
@@ -129,7 +135,7 @@
<ul>
<li>Hetzner Online GmbH: Hosting, E-Mail und Datensicherung (Auftragsverarbeiter)</li>
<li>UptimeRobot s. r. o.: Überwachung der Erreichbarkeit, Daten auch außerhalb der EU (insbesondere USA), Auftragsverarbeiter (Abschnitt 3)</li>
<li>Stripe: Zahlungsabwicklung (Abschnitt 7)</li>
<li>Stripe: Zahlungsabwicklung und Eröffnung des Stripe-Kontos für den Webshop unserer Kunden (Abschnitte 6d und 7)</li>
<li>Porkbun LLC: bisheriger E-Mail-Dienst, nur noch bis zur Löschung älterer E-Mails (Abschnitt 8)</li>
<li>Domain-Registrar und Vergabestellen, sofern wir eine Domain für Sie registrieren (Abschnitt 10)</li>
<li>Steuerberatung und Behörden, soweit gesetzlich erforderlich</li>
+33
View File
@@ -0,0 +1,33 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex">
<title>Webshop Testseite</title>
<style>
body{margin:0;font-family:system-ui,sans-serif;background:#f6f7f9;color:#1f2937;line-height:1.5}
header,footer{padding:16px 24px;background:#e8ebf0}
main{max-width:900px;margin:0 auto;padding:32px 16px}
h2{margin:32px 0 12px}
.fallback{margin:0;padding:16px;border:2px dashed #94a3b8;border-radius:12px;background:#fff}
/* Gestaltung ueber CSS-Variablen der Seite (alle optional); die Akzentfarbe kommt sonst aus den Shop-Einstellungen:
[data-webklar-shop]{ --wks-akzent:#0f766e; --wks-radius:4px; } */
</style>
</head>
<body>
<header>Testseite der Kundenwebsite (intern, nicht verlinkt)</header>
<main>
<h1>Unser Online-Shop</h1>
<!-- Platzhalter-siteKey: durch den Schluessel des Shops ersetzen. data-api weglassen = Herkunft des Skripts.
Der Ausweichtext bleibt ohne JavaScript und bei Fehlern (Shop nicht aktiv, nicht erreichbar) stehen.
Mit data-ansicht="widerruf" oeffnet das Widget gleich die Widerrufsfunktion. -->
<div data-webklar-shop="abcdefghijklmnop">
<p class="fallback" id="ausweich-shop">Unser Shop ist gerade nicht erreichbar. Bestellungen telefonisch unter 06301 1234.</p>
</div>
</main>
<footer>Fußbereich</footer>
<script src="/embed/shop.js" defer></script>
</body>
</html>
+1792
View File
File diff suppressed because it is too large. Load diff
+22
View File
@@ -0,0 +1,22 @@
/* Gemeinsame Hilfen der Funktions-Module (funktionen.js, analyse.js): Escaping fuer Kundentexte, URL-Pruefung, API-Aufruf. */
export const esc = (value) => String(value ?? '')
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;')
export const safeUrl = (url) => (/^https?:\/\//i.test(url || '') ? url : '')
export async function request(path, { method = 'GET', body, raw } = {}) {
const response = await fetch(path, {
method,
credentials: 'same-origin',
headers: raw ? { 'Content-Type': 'application/octet-stream' } : body ? { 'Content-Type': 'application/json' } : {},
body: raw || (body ? JSON.stringify(body) : undefined),
})
const data = await response.json().catch(() => ({}))
if (!response.ok) {
const err = new Error(data.error || (response.status === 502 ? 'Server nicht erreichbar. Bitte kurz warten und noch einmal versuchen.' : `Fehler ${response.status}`))
err.status = response.status
if (data.field) err.field = data.field
throw err
}
return data
}
+63 -24
View File
@@ -4,12 +4,15 @@
Es gibt keine Texte des Registers im Client: Knopf, Haken, Belehrung, Preis und Unterlagen kommen als `bestellung` aus
GET /api/termine/einstellungen (Server: bestellungAnsicht), und der Knopf zeigt genau bestellung.consent.knopf, denselben Text, den der
Server im Nachweis speichert. Fremdtext kommt nur über textContent in die Seite (h aus termine-dom.js), nie über innerHTML.
Zusammenfassung: zusammenfassung() aus termine-view.js. Checkout: POST /api/funktionen/termine/checkout (Server: routes/funktionen.js). */
Zusammenfassung: zusammenfassung() aus termine-view.js (Standard für key 'termine'; andere Funktionen übergeben eine eigene Funktion oder
zeigen keine). Checkout: POST /api/funktionen/<key>/checkout (Server: routes/funktionen.js), key ist standardmäßig 'termine'. */
import { fehlerKarte, h, request } from './termine-dom.js'
import { FRAGEBOGEN_SCHRITTE, zusammenfassung } from './termine-view.js'
const CHECKOUT = '/api/funktionen/termine/checkout'
const STANDARD_KEY = 'termine'
// Checkout-Pfad der Funktion; der Schlüssel ist ein Registerschlüssel (Kleinbuchstaben), alles andere fällt auf den Terminplaner zurück
const checkoutPfad = (key) => `/api/funktionen/${typeof key === 'string' && /^[a-z][a-z0-9-]{0,31}$/.test(key) ? key : STANDARD_KEY}/checkout`
const RECHTLICHES = '/rechtliches/'
const KUNDENANSICHT = 'In der Kundenansicht kann nicht bestellt werden.'
const ZAHLUNGSSEITE = 'Die Zahlungsseite konnte nicht geöffnet werden. Bitte versuch es noch einmal oder schreib uns im Chat.'
@@ -84,22 +87,45 @@ const offeneListe = (wert) => (Array.isArray(wert) ? wert.filter((punkt) => punk
const chatKnopf = () => h('button', { type: 'button', class: 'kb-btn-outline', 'data-view': 'chat' }, 'Frage dazu? Chat öffnen')
/* ----------------------------------------------- Rückkehr von der Zahlungsseite --- */
/**
* Der Browser-Zurück-Knopf der Zahlungsseite stellt die Seite oft aus dem Speicher (bfcache) so wieder her, wie sie verlassen wurde: der Knopf bleibt
* gesperrt, es steht keine Meldung da. Darum laden Bestellseite und Statuskarte bei pageshow mit persisted neu (wie app.js beim Wechsel-Knopf).
* Gilt nur, solange das Element noch im Baum steht; danach entfernt sich der Handler beim nächsten pageshow selbst.
*/
function ladeBeiRueckkehrNeu(element) {
if (typeof window === 'undefined' || typeof window.addEventListener !== 'function') return
const beiAnzeige = (ev) => {
if (!element.isConnected) {
window.removeEventListener('pageshow', beiAnzeige)
return
}
if (ev?.persisted === true) window.location.reload()
}
window.addEventListener('pageshow', beiAnzeige)
}
/* ---------------------------------------------------------------- Bestellseite --- */
/**
* „Prüfen und bestellen“ in root zeichnen und die Seite zurückgeben. Reihenfolge: Zusammenfassung der Angaben (je Gruppe „Ändern“),
* offene Punkte (je Punkt „Zum Schritt“), Preis und Einrichtungsdauer, die vollständige Widerrufsbelehrung samt Muster-Formular als Text
* (ein scrollbarer Bereich, nie eingeklappt), die Unterlagen als Links, die Zustimmungen und zuletzt der Knopf.
* ctx = Kontext der Ansicht (projectId, aktuell(), neuLaden()), daten = Antwort von GET /api/termine/einstellungen im Zustand off
* (einstellung, fragebogen, branchen, offen, kundenansicht, bestellung), zumSchritt(key) springt in einen Schritt des Fragebogens.
* (einstellung, fragebogen, branchen, offen, kundenansicht, bestellung), zumSchritt(key) springt in einen Schritt des Fragebogens (ist es keine Funktion,
* zeichnet die Seite weder „Ändern“ noch „Zum Schritt“).
* Der Knopf ist gesperrt, solange ein Haken fehlt, offene Punkte bleiben oder die Kundenansicht läuft. Ein Klick sperrt ihn, sendet
* bestellKoerper und geht zur Zahlungsseite. Bei 409 lädt die Seite die Daten neu und setzt die Haken zurück (bestellung_offen zeigt die
* Punkte aus der Antwort, fassung_veraltet die neue Fassung aus der Antwort, ein Abo-Code den Text der Antwort).
* Weitere Optionen: key = Schlüssel der Funktion im Register (Standard 'termine', bestimmt den Checkout-Pfad), zusammenfassung =
* Funktion (einstellung, fragebogen, branchen) => [{ schritt, titel, zeilen }]; ohne sie zeigt nur der Terminplaner seine Zusammenfassung,
* andere Funktionen lassen den Block weg.
*/
export function renderBestellen(root, { ctx, daten, zumSchritt } = {}) {
return baueSeite(root, { ctx, daten, zumSchritt }, {})
export function renderBestellen(root, { ctx, daten, zumSchritt, key = STANDARD_KEY, zusammenfassung: gruppenFunktion } = {}) {
const gruppenFn = typeof gruppenFunktion === 'function' ? gruppenFunktion : key === STANDARD_KEY ? zusammenfassung : null
return baueSeite(root, { ctx, daten, zumSchritt, key, gruppenFn }, {})
}
function baueSeite(root, { ctx, daten, zumSchritt }, uebernahme) {
function baueSeite(root, { ctx, daten, zumSchritt, key, gruppenFn }, uebernahme) {
const roh = daten?.bestellung
if (!roh || !istConsent(roh.consent)) {
const karte = fehlerKarte('Die Bestellseite konnte gerade nicht geladen werden. Bitte lade die Seite neu.')
@@ -116,15 +142,19 @@ function baueSeite(root, { ctx, daten, zumSchritt }, uebernahme) {
const gewaehlt = new Set()
let laeuft = false
const gehe = (schritt) => { if (typeof zumSchritt === 'function') zumSchritt(schritt) }
// „Ändern“ und „Zum Schritt“ gibt es nur, wo es ein Ziel gibt: ohne zumSchritt zeichnet die Seite keine Knöpfe, die nichts tun
const springt = typeof zumSchritt === 'function'
const gehe = (schritt) => { if (springt) zumSchritt(schritt) }
// 1 Zusammenfassung: je Gruppe die Angaben als Text und „Ändern“
const gruppen = zusammenfassung(daten.einstellung, daten.fragebogen, daten.branchen).map((gruppe) => h('div', { class: 'tm-bestellen-gruppe', 'data-schritt': gruppe.schritt },
const gruppen = (gruppenFn ? gruppenFn(daten.einstellung, daten.fragebogen, daten.branchen) : []).map((gruppe) => h('div', { class: 'tm-bestellen-gruppe', 'data-schritt': gruppe.schritt },
h('div', { class: 'tm-bestellen-gruppe-kopf' },
h('h4', null, gruppe.titel),
h('button', {
type: 'button', class: 'kb-btn-outline tm-knopf', 'data-aktion': 'aendern', 'data-schritt': gruppe.schritt, 'aria-label': `Ändern: ${gruppe.titel}`, onclick: () => gehe(gruppe.schritt),
}, 'Ändern')),
springt
? h('button', {
type: 'button', class: 'kb-btn-outline tm-knopf', 'data-aktion': 'aendern', 'data-schritt': gruppe.schritt, 'aria-label': `Ändern: ${gruppe.titel}`, onclick: () => gehe(gruppe.schritt),
}, 'Ändern')
: null),
gruppe.zeilen.length > 0 ? h('ul', null, gruppe.zeilen.map((zeile) => h('li', null, zeile))) : h('p', { class: 'tm-hinweis' }, 'Noch keine Angaben.')))
// 2 offene Punkte: „Zum Schritt“ nur dort, wo es einen Schritt des Fragebogens gibt (der Abo-Punkt gehört zu dieser Seite)
@@ -133,7 +163,7 @@ function baueSeite(root, { ctx, daten, zumSchritt }, uebernahme) {
h('p', { class: 'tm-hinweis' }, 'Erledige diese Punkte, dann kannst du bestellen.'),
h('ul', null, offen.map((punkt) => h('li', null,
h('span', null, punkt.text),
SCHRITT_TITEL.has(punkt.schritt)
springt && SCHRITT_TITEL.has(punkt.schritt)
? h('button', {
type: 'button', class: 'kb-btn-outline tm-knopf', 'data-aktion': 'zum-schritt', 'data-schritt': punkt.schritt, 'aria-label': `Zum Schritt „${SCHRITT_TITEL.get(punkt.schritt)}“`, onclick: () => gehe(punkt.schritt),
}, 'Zum Schritt')
@@ -160,7 +190,7 @@ function baueSeite(root, { ctx, daten, zumSchritt }, uebernahme) {
// 6 Zustimmungen in Registerreihenfolge: Einträge mit haken: true sind Haken, die anderen reiner Text
const knopf = h('button', { type: 'button', class: 'kb-btn-primary tm-bestellen-knopf', 'data-aktion': 'bestellen', disabled: true, onclick: () => bestelle() }, text(bestellung.consent.knopf))
const meldungEl = h('p', { class: 'tm-fehler tm-bestellen-meldung', role: 'alert', 'data-meldung': 'bestellen' })
const meldungEl = h('p', { class: 'tm-fehler tm-bestellen-meldung', role: 'alert', 'data-meldung': 'bestellen', tabindex: '-1' })
const hinweisEl = h('p', { class: 'tm-hinweis', 'data-hinweis': 'bestellen' })
const hakenZeile = (eintrag) => {
const feld = h('input', { type: 'checkbox', 'data-haken': eintrag.id })
@@ -177,8 +207,8 @@ function baueSeite(root, { ctx, daten, zumSchritt }, uebernahme) {
const seite = h('section', { class: 'kb-panel tm-bestellen' },
h('h3', null, 'Prüfen und bestellen'),
h('p', { class: 'tm-abschnitt-text tm-hinweis' }, 'Lies alles in Ruhe durch. Mit „Ändern“ kommst du zu dem Schritt zurück, in dem du die Angabe gemacht hast.'),
h('div', { class: 'tm-bestellen-zusammenfassung' }, gruppen),
gruppenFn ? h('p', { class: 'tm-abschnitt-text tm-hinweis' }, springt ? 'Lies alles in Ruhe durch. Mit „Ändern“ kommst du zu dem Schritt zurück, in dem du die Angabe gemacht hast.' : 'Lies alles in Ruhe durch.') : null,
gruppenFn ? h('div', { class: 'tm-bestellen-zusammenfassung' }, gruppen) : null,
offenBlock,
preisBlock,
belehrungBlock,
@@ -186,6 +216,7 @@ function baueSeite(root, { ctx, daten, zumSchritt }, uebernahme) {
zustimmungBlock,
h('div', { class: 'tm-bestellen-aktion' }, meldungEl, knopf, hinweisEl))
root.replaceChildren(seite)
ladeBeiRueckkehrNeu(seite)
function zeigeMeldung(meldung) {
meldungEl.textContent = meldung
@@ -210,22 +241,26 @@ function baueSeite(root, { ctx, daten, zumSchritt }, uebernahme) {
}
if (neu === null || typeof neu !== 'object') {
zeigeMeldung(err.message)
meldungEl.focus({ preventScroll: true })
laeuft = false
aktualisiere()
return
}
if (typeof ctx.aktuell === 'function' && !ctx.aktuell()) return
if (!neu.bestellung) {
root.replaceChildren(h('div', { class: 'kb-panel tm-info', role: 'status' }, h('p', null, err.message), h('p', null, 'Lade die Seite neu, dann siehst du den aktuellen Stand.')))
const karte = h('div', { class: 'kb-panel tm-info', role: 'status', tabindex: '-1' }, h('p', null, err.message), h('p', null, 'Lade die Seite neu, dann siehst du den aktuellen Stand.'))
root.replaceChildren(karte)
karte.focus({ preventScroll: true })
return
}
const neueSeite = baueSeite(root, { ctx, daten: neu, zumSchritt }, {
const neueSeite = baueSeite(root, { ctx, daten: neu, zumSchritt, key, gruppenFn }, {
offen: err.code === 'bestellung_offen' ? err.body?.offen : undefined,
consent: err.code === 'fassung_veraltet' ? err.body?.consent : undefined,
})
const neueMeldung = neueSeite.querySelector('[data-meldung="bestellen"]')
if (neueMeldung) {
neueMeldung.textContent = err.message
neueMeldung.focus({ preventScroll: true })
neueMeldung.scrollIntoView({ block: 'nearest' })
}
}
@@ -236,7 +271,7 @@ function baueSeite(root, { ctx, daten, zumSchritt }, uebernahme) {
aktualisiere()
zeigeMeldung('')
try {
const antwort = await request(CHECKOUT, { method: 'POST', body: bestellKoerper({ projectId: ctx?.projectId, bestellung, gewaehlt }) })
const antwort = await request(checkoutPfad(key), { method: 'POST', body: bestellKoerper({ projectId: ctx?.projectId, bestellung, gewaehlt }) })
if (geheZu(antwort?.url)) return // die Seite wird verlassen, der Knopf bleibt gesperrt
zeigeMeldung(ZAHLUNGSSEITE)
} catch (err) {
@@ -260,13 +295,15 @@ function baueSeite(root, { ctx, daten, zumSchritt }, uebernahme) {
* projekt = Eintrag des Projekts aus GET /api/funktionen (projectId), bestellung = projekt.features.termine (state, orderedAt, ticketWoid,
* invoiceUrl), einrichtung = Text der Zeiteinschätzung (Register: setupEstimate), danke = Rückkehr von Stripe, kundenansicht = Sitzung in der
* Kundenansicht, meldung = Text, der am Knopf „Zahlung fortsetzen“ steht (z. B. der Fehler eines Versuchs vor dem Neuzeichnen).
* key = Schlüssel der Funktion (Standard 'termine', Checkout-Pfad), name = Name der Funktion in
* den Texten (Standard 'Terminplaner'), hinweisEinrichtung = Satz unter den Fakten im Zustand setup (Standard: Einstellungen gesperrt).
* weiter(fall) wird gerufen, wenn „Zahlung fortsetzen“ scheitert (Ruling R110): fall = { bestellen, meldung }, bestellen ist wahr bei 409
* zustimmung_fehlt (keine offene Sitzung mit Nachweis: die Ansicht zeigt wieder das Bestellformular), meldung der Fehlertext der Antwort.
* weiter() lädt neu und zeichnet die Ansicht nach dem Zustand von neuem, damit keine veraltete Karte stehen bleibt (z. B. wenn die Bestellung
* inzwischen bezahlt ist und der Server 400 antwortet); wirft es, bleibt die Karte stehen und der Fehler steht am Knopf. Fehlt weiter,
* steht die Meldung des Servers am Knopf.
*/
export function statusKarte({ projekt, bestellung, einrichtung, danke, kundenansicht, meldung } = {}, { weiter } = {}) {
export function statusKarte({ projekt, bestellung, einrichtung, danke, kundenansicht, meldung, key = STANDARD_KEY, name = 'Terminplaner', hinweisEinrichtung } = {}, { weiter } = {}) {
const bestellt = bestellung?.state === 'ordered'
const datum = datumTag(bestellung?.orderedAt)
const ticket = bestellung?.ticketWoid
@@ -289,7 +326,7 @@ export function statusKarte({ projekt, bestellung, einrichtung, danke, kundenans
fortsetzenKnopf.disabled = true
meldungEl.textContent = ''
try {
const antwort = await request(CHECKOUT, { method: 'POST', body: fortsetzenKoerper(projekt?.projectId) })
const antwort = await request(checkoutPfad(key), { method: 'POST', body: fortsetzenKoerper(projekt?.projectId) })
if (geheZu(antwort?.url)) return // die Seite wird verlassen
meldungEl.textContent = ZAHLUNGSSEITE
} catch (err) {
@@ -310,19 +347,21 @@ export function statusKarte({ projekt, bestellung, einrichtung, danke, kundenans
fortsetzenKnopf.disabled = false
}
return h('section', { class: 'kb-panel tm-statuskarte', 'data-state': bestellt ? 'ordered' : 'setup' },
const karte = h('section', { class: 'kb-panel tm-statuskarte', 'data-state': bestellt ? 'ordered' : 'setup' },
danke ? h('p', { class: 'tm-danke', role: 'status', 'data-danke': true }, DANKE) : null,
h('span', { class: 'tm-pille tm-pille-wait' }, bestellt ? 'Zahlung offen' : 'In Einrichtung'),
h('h3', null, bestellt ? 'Deine Bestellung wartet auf die Zahlung' : 'Wir richten deinen Terminplaner ein'),
h('p', null, bestellt ? 'Sobald deine Zahlung bei uns ankommt, legen wir mit der Einrichtung los.' : 'Sobald die Einrichtung fertig ist, schalten wir deinen Terminplaner für dich frei.'),
h('h3', null, bestellt ? 'Deine Bestellung wartet auf die Zahlung' : `Wir richten deinen ${text(name) || 'Terminplaner'} ein`),
h('p', null, bestellt ? 'Sobald deine Zahlung bei uns ankommt, legen wir mit der Einrichtung los.' : `Sobald die Einrichtung fertig ist, schalten wir deinen ${text(name) || 'Terminplaner'} für dich frei.`),
fakten.length > 0 ? h('dl', { class: 'tm-fakten' }, fakten) : null,
bestellt
? h('p', { class: 'tm-hinweis' }, 'Zahlst du per SEPA-Lastschrift, kann es einige Tage dauern, bis die Zahlung bei uns ankommt.')
: h('p', { class: 'tm-hinweis' }, 'Deine Einstellungen sind jetzt gesperrt, Änderungen laufen über den Chat.'),
: h('p', { class: 'tm-hinweis' }, text(hinweisEinrichtung) || 'Deine Einstellungen sind jetzt gesperrt, Änderungen laufen über den Chat.'),
meldungEl,
bestellt && kundenansicht === true ? h('p', { class: 'tm-hinweis' }, KUNDENANSICHT) : null,
h('div', { class: 'tm-aktionen' },
fortsetzenKnopf,
!bestellt && rechnung ? h('a', { class: 'kb-btn-outline', href: sicherLink(rechnung), target: '_blank', rel: 'noopener noreferrer' }, 'Rechnung ansehen') : null,
chatKnopf()))
ladeBeiRueckkehrNeu(karte)
return karte
}
-79
View File
@@ -137,82 +137,3 @@
.fn-tiles { grid-template-columns: minmax(0, 1fr); }
.fn-facts dt { min-width: 0; flex: 1 0 100%; }
}
/* ------------------------------------------------------------- Analyse --- */
#view-analyse .kb-panel { max-width: none; }
#view-analyse .an-panel { max-width: 720px; }
#view-analyse .fn-project { max-width: 640px; margin-bottom: 16px; display: flex; align-items: center; gap: 12px; flex-wrap: wrap; padding: 14px 18px; }
#view-analyse .kb-panel + .kb-panel { margin-top: 16px; }
.an-panel p { margin: 0 0 12px; line-height: 1.55; }
.an-h4 { margin: 18px 0 8px; font-size: 14px; }
.an-colors { display: flex; align-items: center; flex-wrap: wrap; gap: 12px; }
.an-colors input[type=color] { width: 48px; height: 40px; padding: 2px; border-radius: 10px; border: 1px solid var(--kb-border-strong); background: transparent; cursor: pointer; }
.an-presets { display: flex; flex-wrap: wrap; gap: 8px; }
.an-preset { width: 32px; height: 32px; border-radius: 50%; border: 2px solid var(--kb-border-strong); cursor: pointer; padding: 0; }
.an-hint { margin: 10px 0 0; font-size: 13px; color: #fcd34d; }
/* Banner-Vorschau: gleiche Optik wie der echte Banner (analytics/client/w.js), immer hell */
.an-bn { margin: 16px 0; padding: 14px 20px; display: flex; align-items: center; gap: 16px; flex-wrap: wrap; background: #fff; color: #111; border: 1px solid #d0d0d0; border-radius: 12px; font: 15px/1.45 system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif; }
.an-bn-text { flex: 1 1 260px; min-width: 0; margin: 0 !important; color: #111 !important; font-size: 15px !important; line-height: 1.45 !important; }
.an-bn-link { text-decoration: underline; }
.an-bn-row { display: flex; gap: 10px; flex: none; }
.an-bn-btn { display: inline-flex; align-items: center; justify-content: center; box-sizing: border-box; width: 150px; height: 44px; padding: 0 20px; border: 2px solid; border-radius: 8px; font: 600 15px/1 system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif; }
.an-range { display: flex; gap: 8px; margin-bottom: 16px; flex-wrap: wrap; }
.an-tab { padding: 8px 16px; border-radius: 999px; border: 1px solid var(--kb-border-strong); background: transparent; color: inherit; font: inherit; font-size: 14px; font-weight: 700; cursor: pointer; }
.an-tab.active { background: var(--kb-purple-soft); border-color: var(--kb-purple-line); }
.an-kpis { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; margin-bottom: 16px; }
.an-kpi { padding: 14px 16px; border-radius: 14px; background: var(--kb-surface); border: 1px solid var(--kb-border); min-width: 0; }
.an-kpi span { display: block; font-size: 13px; color: var(--kb-muted); }
.an-kpi b { display: block; margin-top: 4px; font-size: 26px; font-variant-numeric: tabular-nums; }
.an-chart-box { margin-bottom: 16px; }
.an-chart { display: block; width: 100%; height: auto; }
.an-grid { stroke: var(--kb-border-strong); stroke-width: 1; }
.an-axis { fill: var(--kb-muted); font-size: 11px; }
.an-line { fill: none; stroke: var(--kb-lilac); stroke-width: 2.5; stroke-linejoin: round; stroke-linecap: round; }
.an-area { fill: var(--kb-purple-soft); stroke: none; }
.an-dot { fill: var(--kb-lilac); }
.an-grid-lists { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 16px; align-items: start; }
#view-analyse .an-grid-lists .kb-panel { margin-top: 0; }
.an-rows { list-style: none; margin: 0; padding: 0; display: grid; gap: 10px; }
.an-row-top { display: flex; justify-content: space-between; gap: 12px; font-size: 14px; }
.an-row-label { min-width: 0; overflow-wrap: anywhere; }
.an-row-top b { font-variant-numeric: tabular-nums; }
.an-bar { height: 6px; margin-top: 4px; border-radius: 3px; background: rgba(255, 255, 255, 0.07); overflow: hidden; }
.an-bar i { display: block; height: 100%; border-radius: 3px; background: var(--kb-purple); }
.an-note, .an-credit { margin: 16px 0 0; font-size: 12.5px; line-height: 1.5; color: var(--kb-muted); }
.an-credit { font-size: 11.5px; color: var(--kb-faint); }
.an-credit a { color: inherit; }
.an-heat { margin-top: 16px; }
.an-heat p, .an-off p { margin: 0 0 12px; line-height: 1.55; }
.an-heat-row { display: flex; flex-wrap: wrap; gap: 12px; margin-bottom: 12px; }
.an-heat-row label { display: grid; gap: 4px; font-size: 13px; font-weight: 600; flex: 1 1 200px; min-width: 0; }
.an-heat-row select { padding: 10px 12px; border-radius: 12px; border: 1px solid var(--kb-border-strong); background: rgba(255, 255, 255, 0.05); color: inherit; font: inherit; min-width: 0; }
.an-heat-row select option { color: #111; }
/* Heatmap-Buehne: grosses Geraet (Computer oder Tablet) und Handy nebeneinander, unten buendig (Rahmen: geraete.css) */
.an-heat .hs-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; margin-bottom: 10px; }
.an-heat .hs-head h3 { margin: 0; }
.hs-switch { display: flex; gap: 8px; }
.hs-stage { display: grid; grid-template-columns: minmax(0, 3.7fr) minmax(0, 1fr); align-items: end; gap: clamp(16px, 3vw, 40px); max-width: 980px; margin: 24px auto 4px; }
.hs-stage[data-big="t"] { grid-template-columns: minmax(0, 2.1fr) minmax(0, 1fr); max-width: 620px; }
.hs-dev { margin: 0; min-width: 0; display: flex; flex-direction: column; align-items: center; gap: 14px; }
.hs-frame { width: 100%; }
.hs-cap { min-height: 1.5em; font-size: 13px; line-height: 1.5; color: var(--kb-muted); text-align: center; }
.hs-dev .dm-canvas { background: #fff; }
.hs-site { position: absolute; left: 0; background: #fff; }
.hs-site iframe { display: block; width: 100%; height: 100%; border: 0; background: #fff; }
@media (max-width: 640px) {
.hs-stage, .hs-stage[data-big="t"] { grid-template-columns: minmax(0, 1fr); gap: 28px; }
.hs-dev--phone .hs-frame { width: 62%; }
}
.an-off { margin-top: 16px; }
.an-ghost { padding: 9px 16px; border-radius: 999px; border: 1px solid var(--kb-border-strong); background: transparent; color: inherit; font: inherit; font-size: 13px; font-weight: 700; cursor: pointer; }
.an-ghost:hover { background: rgba(255, 255, 255, 0.07); }
.an-danger { color: #fca5a5; }
@media (max-width: 880px) {
.an-kpis { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.an-grid-lists { grid-template-columns: minmax(0, 1fr); }
.an-bn { display: block; margin: 12px 0; padding: 16px; }
.an-bn-row { flex-direction: column; margin-top: 12px; }
.an-bn-btn { width: 100%; }
}
+57 -401
View File
@@ -3,8 +3,8 @@
Buchungsseite (off), Einrichtungsstatus (ordered/setup) oder Editor (active). Styles: funktionen.css.
Kundentexte kommen nur ueber esc() bzw. textContent in die Seite. */
import { bannerColor, BANNER_PREVIEW_TEXT, controllerLines, chartModel, barRows, countryName, flagEmoji, refLabel, DEVICE_LABELS, CONTACT_LABELS } from '/analyse-view.js'
import { HEAT_DEVICES, deviceHtml, heatCaption } from '/geraete.js'
import { esc, safeUrl, request } from '/fn-util.js'
import { analyseOf, analyseState, pickAnalyseProject, analyseSel } from '/analyse-view.js'
import { ZU_VERTRAEGE } from './vertraege-link.js'
const PRICE = '30 €' // Rueckfall, wenn die Antwort von /api/funktionen kein features.news (Register) enthaelt
@@ -28,10 +28,6 @@ const TYPES = [
['combo', 'Foto + Text', 'beides', '<rect x="3" y="3" width="12" height="12" rx="2"/><path d="M9 21h10a2 2 0 0 0 2-2V9"/><path d="m3 11 3-3 4 4"/>'],
]
const esc = (value) => String(value ?? '')
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;')
const safeUrl = (url) => (/^https?:\/\//i.test(url || '') ? url : '')
function fmtDay(value) {
const d = new Date(value)
if (!value || Number.isNaN(d.getTime())) return ''
@@ -44,23 +40,6 @@ function berlinIsoDay(offsetDays = 0) {
return d.toLocaleDateString('sv-SE', { timeZone: 'Europe/Berlin' })
}
async function request(path, { method = 'GET', body, raw } = {}) {
const response = await fetch(path, {
method,
credentials: 'same-origin',
headers: raw ? { 'Content-Type': 'application/octet-stream' } : body ? { 'Content-Type': 'application/json' } : {},
body: raw || (body ? JSON.stringify(body) : undefined),
})
const data = await response.json().catch(() => ({}))
if (!response.ok) {
const err = new Error(data.error || (response.status === 502 ? 'Server nicht erreichbar. Bitte kurz warten und noch einmal versuchen.' : `Fehler ${response.status}`))
err.status = response.status
if (data.field) err.field = data.field
throw err
}
return data
}
/* ------------------------------------------------------------ Zustand --- */
// Das im News-Bereich gewaehlte Projekt bleibt beim Wechseln zwischen Kacheln und News erhalten
const selection = { projectId: '' }
@@ -92,6 +71,7 @@ const ICONS = {
news: '<path d="M12 17v5"/><path d="M9 10.8a2 2 0 0 1-1.1 1.8l-1.8.9A2 2 0 0 0 5 15.2V16a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-.8a2 2 0 0 0-1.1-1.8l-1.8-.9A2 2 0 0 1 15 10.8V7a1 1 0 0 1 1-1 2 2 0 0 0 0-4H8a2 2 0 0 0 0 4 1 1 0 0 1 1 1z"/>',
calendar: '<rect x="3" y="5" width="18" height="16" rx="3"/><path d="M8 3v4M16 3v4M3 10h18"/>',
chart: '<path d="M4 20V10M10 20V4M16 20v-7M22 20H2"/>',
shop: '<circle cx="9" cy="20" r="1.5"/><circle cx="18" cy="20" r="1.5"/><path d="M2 3h3l2.7 12.4a1 1 0 0 0 1 .8h9.1a1 1 0 0 0 1-.8L21 7H6"/>',
clock: '<circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/>',
}
const svg = (name) => `<svg class="fn-ico" viewBox="0 0 24 24" aria-hidden="true">${ICONS[name]}</svg>`
@@ -121,15 +101,9 @@ function newsTile(projects, price = PRICE) {
const ZU_VERTRAEGE_LINK = `<button type="button" class="${ZU_VERTRAEGE.klasse}" data-view="${ZU_VERTRAEGE.view}">${ZU_VERTRAEGE.text}</button>`
/* Analyse: Kachel je nach Zustand der Websites (comingSoon = Schalter aus) */
const analyseOf = (project) => project.analyse || { comingSoon: true }
const analyseState = (a) => (a.comingSoon ? 'soon' : a.state === 'unknown' ? 'unknown' : a.state === 'disabled' ? 'disabled' : a.state === 'active' ? 'active' : a.eligible ? 'off' : 'blocked')
const DEV_HINT = 'Im Dev-Modus wird nicht gemessen.'
const ANALYSE_TEXT = 'Sieh, wie viele Besucher deine Website hat und was sie sich ansehen.'
function pickAnalyseProject(projects) {
return projects.find((p) => analyseState(analyseOf(p)) === 'active') || projects.find((p) => analyseState(analyseOf(p)) === 'off') || projects[0]
}
function analyseTile(projects) {
const target = pickAnalyseProject(projects)
const a = target && analyseOf(target)
@@ -239,6 +213,53 @@ async function showTermineCount(root, projects) {
el.hidden = false
}
/* Webshop: Kachel nur, wenn der Server den Shop fuer diesen Kunden freigibt (shop.verfuegbar der Antwort, Schalter SHOP_ENABLED); sonst
gar keine Kachel (der Shop startet im Pilot und wird nicht vorab angekuendigt). Die Ansicht #webshop (shop.js) zeigt die Anfrage und ihren
Stand; die Kachel kennt nur den Zustand des Funktionsdokuments (projects[].features.shop: off | ordered | setup | active | disabled) und
baut sich inline wie die Terminplaner-Kachel, damit funktionen.js keinen weiteren Import braucht. Der Preis kommt aus dem Register
(features.shop.priceLabel), Rueckfall 299 €. */
const SHOP_PREIS = '299 €'
const shopOf = (project) => project.features?.shop || { state: 'off' }
// Zielprojekt: aktiv, dann ordered/setup, dann abgeschaltet, dann das erste
function pickShopProject(projects) {
return projects.find((p) => shopOf(p).state === 'active')
|| projects.find((p) => isSetup(shopOf(p).state))
|| projects.find((p) => shopOf(p).state === 'disabled')
|| projects[0]
}
function shopTile(projects, features) {
const preis = textOr(features?.shop?.priceLabel, SHOP_PREIS)
const active = projects.filter((p) => shopOf(p).state === 'active').length
const target = pickShopProject(projects)
const state = target ? shopOf(target).state : 'off'
const knopf = (text) => `<button type="button" class="kb-btn-primary fn-tile-cta" data-view="webshop" data-project="${esc(target.projectId)}">${text}</button>`
// Zustand -> Pille und Knopf (aus: noch keine Funktion, die Anfrage selbst zeigt die Ansicht #webshop)
const ZUSTAENDE = {
off: [['Aus', 'off'], 'Webshop anfragen'],
ordered: [['Zahlung offen', 'setup'], 'Zahlung offen'],
setup: [['In Einrichtung', 'setup'], 'In Einrichtung'],
active: [['Aktiv', 'on'], 'Webshop öffnen'],
disabled: [['Abgeschaltet', 'off'], 'Webshop ansehen'],
}
const [pill, knopfText] = ZUSTAENDE[state] || ZUSTAENDE.off
const action = target ? knopf(knopfText) : ''
let extra = ''
if (!target) extra = '<p class="fn-count">Sobald deine Website angelegt ist, kannst du den Webshop anfragen.</p>'
else if (state === 'disabled') extra = '<p>Der Webshop ist für diese Website abgeschaltet. Deine Bestellungen kannst du noch ansehen.</p>'
const count = projects.length > 1 && active ? `<p class="fn-count">aktiv für ${active} von ${projects.length} Websites</p>` : ''
return `
<article class="fn-tile" data-fn-tile="shop">
<div class="fn-tile-head">${svg('shop')}<span class="fn-pill fn-pill-${pill[1]}">${pill[0]}</span></div>
<h3>Webshop</h3>
<p>Dein eigener Online-Shop auf deiner Website. Zahlungen gehen direkt auf dein Stripe-Konto. ${esc(preis)} einmalig.</p>
${extra}
${count}
${action}
</article>`
}
/* Oeffnungszeiten live: Kachel nur, wenn der Schalter es erlaubt (zeiten.verfuegbar); sonst gibt es sie gar nicht (auch kein "Kommt bald").
Wie bei den anderen Funktionen steht sie inline, die Ansicht #zeiten (zeiten.js) laedt app.js. Alles, was sie zeigt, kommt mit der Liste
(GET /api/funktionen): zeiten (Angebot) mit inklusive (true: im laufenden, aktiven Wartungs-Abo enthalten, einschaltbar | false: sonst, dann
@@ -365,11 +386,12 @@ const soonTile = (icon, title, text) => `
export async function initFunktionen(root) {
if (!root) return
const { projects = [], termine, zeiten, features } = await request('/api/funktionen')
const { projects = [], termine, features, shop, zeiten } = await request('/api/funktionen')
root.innerHTML = `
<div class="fn-tiles">
${newsTile(projects, newsOffer(features).price)}
${termine?.verfuegbar ? termineTile(projects, termine) : soonTile('calendar', 'Terminplaner', TERMINE_TEXT)}
${shop?.verfuegbar ? shopTile(projects, features) : ''}
${zeiten?.verfuegbar === true ? zeitenTile(projects, zeiten) : ''}
${analyseTile(projects)}
</div>`
@@ -380,6 +402,11 @@ export async function initFunktionen(root) {
const body = document.getElementById('termine-body')
if (body) body.dataset.wunschProjekt = ev.currentTarget.dataset.project
})
// Die Webshop-Ansicht liest das gewaehlte Projekt aus ihrem Behaelter (shop.js, initShop)
root.querySelector('[data-fn-tile="shop"] .fn-tile-cta[data-project]')?.addEventListener('click', (ev) => {
const body = document.getElementById('webshop-body')
if (body) body.dataset.wunschProjekt = ev.currentTarget.dataset.project
})
// Die Zeiten-Ansicht liest Projekt und Weg (Behalten) aus ihrem Behaelter (zeiten.js, initZeiten); der Klick steigt danach zum Navigationshandler auf
root.querySelector('[data-fn-tile="zeiten"]')?.addEventListener('click', (ev) => {
const knopf = ev.target?.closest?.('button[data-view="zeiten"]')
@@ -784,374 +811,3 @@ function renderEditor(stage, project) {
sync()
return loadList()
}
/* -------------------------------------------------------------- Analyse --- */
const analyseSel = { projectId: '', range: 30 }
const PRESET_COLORS = ['#111111', '#1d4ed8', '#047857', '#b91c1c', '#7c3aed', '#c2410c']
const HEX_RE = /^#[0-9a-f]{6}$/i
const nf = new Intl.NumberFormat('de-DE')
const KPI_NOTE = '* Besucher, Verweildauer und Klicks zählen nur Besucher, die zugestimmt haben. Seitenaufrufe sind vollständig.'
export async function initAnalyse(root, { projectId } = {}) {
if (!root) return
if (projectId) analyseSel.projectId = projectId
const { projects = [] } = await request('/api/funktionen')
const usable = projects.filter((p) => !analyseOf(p).comingSoon)
if (!usable.length) {
root.innerHTML = '<div class="kb-panel kb-empty">Die Analyse ist für deine Websites noch nicht freigeschaltet.</div>'
return
}
const project = usable.find((p) => p.projectId === analyseSel.projectId) || pickAnalyseProject(usable)
analyseSel.projectId = project.projectId
root.innerHTML = `
${usable.length > 1 ? `
<div class="fn-project kb-panel">
<label for="an-project">Website</label>
<select id="an-project">${usable.map((p) => `<option value="${esc(p.projectId)}"${p.projectId === project.projectId ? ' selected' : ''}>${esc(p.projectName)}</option>`).join('')}</select>
</div>` : ''}
<div id="an-stage"></div>`
root.querySelector('#an-project')?.addEventListener('change', (ev) => {
analyseSel.projectId = ev.target.value
initAnalyse(root).catch((err) => { root.innerHTML = `<p class="muted">${esc(err.message)}</p>` })
})
const stage = root.querySelector('#an-stage')
const a = analyseOf(project)
const reload = () => initAnalyse(root).catch((err) => { root.innerHTML = `<p class="muted">${esc(err.message)}</p>` })
if (a.state === 'active') renderAnalyseStats(stage, project, reload)
else if (a.state === 'disabled') stage.innerHTML = '<div class="kb-panel"><h3>Analyse ist abgeschaltet</h3><p>Für diese Website ist die Analyse im Moment gesperrt. <button type="button" class="kb-link" data-view="chat">Schreib uns im Chat</button>, dann klären wir das.</p></div>'
else if (a.state === 'unknown') stage.innerHTML = '<div class="kb-panel"><p>Die Daten konnten gerade nicht geladen werden.</p></div>'
else if (!a.eligible) stage.innerHTML = `<div class="kb-panel"><h3>Analyse nicht verfügbar</h3><p>${esc(a.reasonText || 'Für diese Website gerade nicht verfügbar.')}${a.reason === 'kein_abo' ? ` ${ZU_VERTRAEGE_LINK}` : ''}</p></div>`
else renderAnalyseActivation(stage, project, reload)
}
function bannerPreview(color) {
const c = bannerColor(color)
const btn = `style="background:${c};border-color:${c};color:#fff"`
return `
<div class="an-bn" role="img" aria-label="Vorschau des Banners auf deiner Website">
<p class="an-bn-text">${esc(BANNER_PREVIEW_TEXT)}<span class="an-bn-link">Datenschutz</span></p>
<div class="an-bn-row"><span class="an-bn-btn" ${btn}>Ablehnen</span><span class="an-bn-btn" ${btn}>Annehmen</span></div>
</div>`
}
const MISSING_PRIVACY_HINT = 'Für die Zusatzseite fehlen in Deinen Kundendaten Firma oder Name und eine gültige E-Mail-Adresse. Schreib uns im Chat, wir ergänzen sie.'
// Vorschau der Verantwortlichen-Angaben; sind sie unvollstaendig, bleibt "Analyse einschalten" gesperrt
function controllerBlock(controller) {
if (controller.ok !== true) return `<p class="error an-missing" id="an-missing" role="alert">${esc(MISSING_PRIVACY_HINT)}</p>`
return `<div class="an-ctl" id="an-ctl"><p>Auf der Datenschutz-Zusatzseite Deiner Website erscheinen als Verantwortlicher:</p><p><strong>${controllerLines(controller).map(esc).join(' · ')}</strong></p><p>Stimmt etwas nicht? Schreib uns im Chat, bevor Du einschaltest.</p></div>`
}
export function renderAnalyseActivation(stage, project, reload) {
const a = analyseOf(project)
const legal = a.legal || {}
const controller = a.controller || { ok: false }
let color = HEX_RE.test(a.accentColor || '') ? a.accentColor.toLowerCase() : '#111111'
stage.innerHTML = `
<div class="kb-panel an-panel">
<h3>Analyse für ${esc(project.projectName)} einschalten</h3>
<p>Du siehst, wie oft deine Website aufgerufen wird, woher die Besucher kommen und was sie sich ansehen.</p>
<p>Ohne Zustimmung der Besucher zählen wir nur Seitenaufrufe mit Land und Geräteklasse, ohne Kennung. Wer zustimmt, wird zusätzlich mit Herkunftsseite, Verweildauer, Scrolltiefe und Klicks gemessen. Wir speichern keine IP-Adressen und setzen keine Cookies. Die Messung läuft auf unserem Server bei Hetzner in Deutschland. Wer ablehnt, wird nicht weiter gemessen; gezählt wird nur, dass abgelehnt wurde.</p>
<h4 class="an-h4">Farbe des Banners</h4>
<div class="an-colors">
<input type="color" id="an-color" value="${esc(color)}" aria-label="Farbe wählen">
<div class="an-presets" role="group" aria-label="Vorgaben">${PRESET_COLORS.map((c) => `<button type="button" class="an-preset" data-color="${c}" style="background:${c}" aria-label="Farbe ${c}"></button>`).join('')}</div>
</div>
<p class="an-hint hidden" id="an-hint">Diese Farbe ist für weiße Schrift zu hell. Der Banner nutzt dann Schwarz (#111111).</p>
<div id="an-preview"></div>
${controllerBlock(controller)}
<label class="fn-consent"><input type="checkbox" id="an-avv"><span>Ich beauftrage WEBklar mit der Website-Analyse und stimme dem <a href="${esc(legal.avv || '')}" target="_blank" rel="noopener">Auftragsverarbeitungsvertrag Website-Analyse</a> und der <a href="${esc(legal.lb || '')}" target="_blank" rel="noopener">Leistungsbeschreibung Website-Analyse</a> (Fassung ${esc(a.avvVersion)}) zu. Meine Website richtet sich nicht gezielt an Kinder unter 16 Jahren.</span></label>
<p class="error hidden" id="an-error" role="alert"></p>
<div class="abo-actions"><button type="button" class="abo-btn" id="an-on" disabled>Analyse einschalten</button></div>
</div>`
const input = stage.querySelector('#an-color')
const hint = stage.querySelector('#an-hint')
const paint = () => {
stage.querySelector('#an-preview').innerHTML = bannerPreview(color)
hint.classList.toggle('hidden', bannerColor(color) === color.toLowerCase())
}
paint()
input.addEventListener('input', () => { color = input.value; paint() })
stage.querySelectorAll('.an-preset').forEach((b) => b.addEventListener('click', () => { color = b.dataset.color; input.value = color; paint() }))
const box = stage.querySelector('#an-avv')
const button = stage.querySelector('#an-on')
const error = stage.querySelector('#an-error')
box.addEventListener('change', () => { button.disabled = !box.checked || controller.ok !== true })
button.addEventListener('click', async () => {
if (!box.checked || controller.ok !== true) return
button.disabled = true
error.classList.add('hidden')
try {
await request('/api/funktionen/analyse/activate', { method: 'POST', body: { projectId: project.projectId, accentColor: color.toLowerCase(), consent: { accepted: true, avvVersion: a.avvVersion } } })
reload()
} catch (err) {
error.textContent = err.message
error.classList.remove('hidden')
button.disabled = !box.checked || controller.ok !== true
}
})
}
function lineChart(series) {
const m = chartModel(series)
if (!m.points.length) return '<p class="nw-empty">Noch keine Daten.</p>'
const total = m.points.reduce((sum, p) => sum + p.pv, 0)
const first = m.points[0].day
const last = m.points[m.points.length - 1].day
return `
<svg class="an-chart" viewBox="0 0 ${m.width} ${m.height}" role="img" aria-labelledby="an-chart-title">
<title id="an-chart-title">Seitenaufrufe pro Tag vom ${esc(first)} bis ${esc(last)}, insgesamt ${esc(nf.format(total))}</title>
${m.yTicks.map((t) => `<line class="an-grid" x1="${m.pad.l}" x2="${m.width - m.pad.r}" y1="${t.y}" y2="${t.y}"/><text class="an-axis" x="${m.pad.l - 6}" y="${t.y + 4}" text-anchor="end">${esc(t.label)}</text>`).join('')}
${m.xLabels.map((l) => `<text class="an-axis" x="${l.x}" y="${m.height - 8}" text-anchor="middle">${esc(l.label)}</text>`).join('')}
<path class="an-area" d="${m.area}"/>
<path class="an-line" d="${m.line}"/>
${m.points.length <= 31 ? m.points.map((p) => `<circle class="an-dot" cx="${p.x}" cy="${p.y}" r="3"><title>${esc(p.day)}: ${esc(nf.format(p.pv))}</title></circle>`).join('') : ''}
</svg>`
}
function barList(title, items, labelOf, { html = false } = {}) {
const rows = barRows(items, labelOf)
return `
<section class="kb-panel an-list">
<h3>${esc(title)}</h3>
${rows.length ? `<ul class="an-rows">${rows.map((r) => `
<li><div class="an-row-top"><span class="an-row-label">${html ? r.label : esc(r.label)}</span><b>${esc(nf.format(r.count))}</b></div><div class="an-bar"><i style="width:${r.pct}%"></i></div></li>`).join('')}</ul>` : '<p class="nw-empty">Noch keine Daten.</p>'}
</section>`
}
const COUNTRY_ROW = (key) => `${flagEmoji(key) ? `<span aria-hidden="true">${flagEmoji(key)}</span> ` : ''}${esc(countryName(key))}`
let statsRequestId = 0
async function renderAnalyseStats(stage, project, reload) {
const myRequest = ++statsRequestId
const range = [7, 30, 90].includes(analyseSel.range) ? analyseSel.range : 30
const pid = project.projectId
stage.innerHTML = `
<div class="an-range" role="tablist" aria-label="Zeitraum">${[7, 30, 90].map((r) => `<button type="button" role="tab" class="an-tab${r === range ? ' active' : ''}" aria-selected="${r === range}" data-range="${r}">${r} Tage</button>`).join('')}</div>
<div id="an-data"><p class="kb-empty">Lade…</p></div>
<section class="kb-panel an-heat" id="an-heat"></section>
<section class="kb-panel an-off" id="an-off"></section>
<p class="an-credit">Länderdaten: IP-Geolocation by <a href="https://db-ip.com" target="_blank" rel="noopener">DB-IP</a></p>`
stage.querySelectorAll('.an-tab').forEach((b) => b.addEventListener('click', () => { analyseSel.range = Number(b.dataset.range); renderAnalyseStats(stage, project, reload) }))
renderAnalyseOff(stage.querySelector('#an-off'), pid, reload)
const data = stage.querySelector('#an-data')
let stats
try {
stats = await request(`/api/funktionen/analyse/stats?projectId=${encodeURIComponent(pid)}&range=${range}`)
} catch (err) {
if (!stage.isConnected || myRequest !== statsRequestId) return
data.innerHTML = `<p class="error" role="alert">${esc(err.status === 503 ? 'Auswertung gerade nicht erreichbar' : err.message)}</p>`
return
}
if (!stage.isConnected || myRequest !== statsRequestId) return
const t = stats.totals || {}
const rate = Math.round((Number(t.consentRate) || 0) * 100)
const kpi = (label, value) => `<div class="an-kpi"><span>${label}</span><b>${esc(value)}</b></div>`
const b = stats.banner || {}
data.innerHTML = `
<div class="an-kpis">${kpi('Seitenaufrufe', nf.format(t.pv || 0))}${kpi('Besucher*', nf.format(t.visitors || 0))}${kpi('Kontakt-Klicks*', nf.format(t.contacts || 0))}${kpi('Zustimmung', `${rate} %`)}</div>
<section class="kb-panel an-chart-box"><h3>Seitenaufrufe pro Tag</h3>${lineChart(stats.series)}</section>
<div class="an-grid-lists">
${barList('Meistbesuchte Seiten', stats.pages, (k) => k || '/')}
${barList('Herkunft', stats.refs, refLabel)}
${barList('Länder', stats.countries, COUNTRY_ROW, { html: true })}
${barList('Geräte', stats.devices, (k) => DEVICE_LABELS[k] || 'Unbekannt')}
${barList('Kontakt-Klicks', stats.contacts, (k) => CONTACT_LABELS[k] || k)}
${barList('Banner', [{ key: 'shown', count: b.shown }, { key: 'accept', count: b.accept }, { key: 'reject', count: b.reject }], (k) => ({ shown: 'Angezeigt', accept: 'Angenommen', reject: 'Abgelehnt' }[k]))}
</div>
<p class="an-note">${esc(KPI_NOTE)}</p>`
renderAnalyseHeat(stage.querySelector('#an-heat'), pid, stats.pages || [])
}
/* Heatmap: die echte Website in Geraeterahmen (gross: Computer oder Tablet, daneben das Handy), darueber die Klicks.
Die Website laeuft im iframe in echter Breite; h.js zeichnet dort das Overlay, sperrt Links und meldet die Klickzahl
per postMessage. Geraeterahmen: geraete.js + geraete.css (wird erst hier geladen). */
const heatSel = { big: 'd' }
const HEAT_TIMEOUT_MS = 20000
let geraeteCss = null
function loadGeraeteCss() {
if (!geraeteCss) {
geraeteCss = new Promise((resolve) => {
const link = document.createElement('link')
link.rel = 'stylesheet'
link.href = '/geraete.css'
link.onload = link.onerror = () => resolve()
document.head.appendChild(link)
})
}
return geraeteCss
}
// Nachricht von h.js aus einem der Rahmen: Klickzahl oder Fehler
let heatListening = false
function onHeatMessage(ev) {
const d = ev.data
if (!d || d.wk !== 'heat') return
for (const fig of document.querySelectorAll('.hs-dev')) {
const frame = fig.querySelector('iframe')
if (!frame || frame.contentWindow !== ev.source || ev.origin !== fig.dataset.origin) continue
clearTimeout(fig.heatTimer)
fig.dataset.state = d.state === 'ready' ? 'ready' : 'error'
setHeatCaption(fig, heatCaption(fig.dataset.state, Number(d.count) || 0, (n) => nf.format(n)))
}
}
function setHeatCaption(fig, text) {
const cap = fig.querySelector('.hs-cap')
if (cap) cap.textContent = text
}
// Bildschirmflaeche (dm-canvas) in Designgroesse per scale() einpassen, wie device-frames.js der Website
function fitHeatDevice(fig) {
fig.heatResize?.disconnect()
const canvas = fig.querySelector('.dm-canvas')
const host = canvas?.parentElement
if (!host) return
const fit = (width) => {
if (width > 0) canvas.style.setProperty('--dm-k', String(Math.round((width / Number(canvas.dataset.designWidth)) * 1e6) / 1e6))
}
fit(parseFloat(getComputedStyle(host).width) || host.clientWidth)
if (typeof ResizeObserver === 'function') {
fig.heatResize = new ResizeObserver((entries) => fit(entries[entries.length - 1].contentRect.width))
fig.heatResize.observe(host)
}
}
async function loadHeatDevice(fig, projectId, vp, path) {
const run = (fig.heatRun = (fig.heatRun || 0) + 1)
clearTimeout(fig.heatTimer)
fig.heatResize?.disconnect()
fig.dataset.state = 'loading'
fig.innerHTML = `<div class="hs-frame"></div><figcaption class="hs-cap" aria-live="polite">${esc(heatCaption('loading'))}</figcaption>`
let url
try {
;({ url } = await request('/api/funktionen/analyse/heatmap-link', { method: 'POST', body: { projectId, path, vp, embed: true } }))
} catch (err) {
if (run === fig.heatRun) setHeatCaption(fig, err.status === 503 ? 'Auswertung gerade nicht erreichbar' : err.message)
return
}
await loadGeraeteCss()
if (run !== fig.heatRun || !fig.isConnected) return
if (!safeUrl(url)) return setHeatCaption(fig, heatCaption('error'))
fig.dataset.origin = new URL(url).origin
fig.querySelector('.hs-frame').innerHTML = deviceHtml(vp, { src: url, title: `Heatmap ${HEAT_DEVICES[vp].label}, Seite ${path}` })
fitHeatDevice(fig)
// Seitenwechsel im Rahmen (Links sperrt h.js, Skripte der Seite koennten trotzdem wechseln): Heatmap neu laden
let loads = 0
fig.querySelector('iframe').addEventListener('load', () => {
if (++loads > 1 && run === fig.heatRun && (fig.heatReloads = (fig.heatReloads || 0) + 1) <= 3) loadHeatDevice(fig, projectId, vp, path)
})
fig.heatTimer = setTimeout(() => {
if (run === fig.heatRun && fig.dataset.state === 'loading') showHeatFallback(fig, projectId, vp, path)
}, HEAT_TIMEOUT_MS)
}
// Website laesst sich nicht einbetten (oder das Analyse-Skript fehlt): Heatmap im eigenen Tab wie bisher
async function showHeatFallback(fig, projectId, vp, path) {
fig.dataset.state = 'timeout'
const cap = fig.querySelector('.hs-cap')
if (!cap) return
cap.textContent = heatCaption('timeout')
try {
const { url } = await request('/api/funktionen/analyse/heatmap-link', { method: 'POST', body: { projectId, path, vp } })
// Kein window.open nach await (Safari/iOS blockiert das): sichtbarer Link, den der Kunde selbst antippt
if (safeUrl(url) && cap.isConnected) cap.insertAdjacentHTML('beforeend', ` <a class="kb-link" href="${esc(url)}" target="_blank" rel="noopener">In neuem Tab öffnen</a>`)
} catch {
/* Text reicht */
}
}
function renderAnalyseHeat(box, projectId, pages) {
if (!box) return
if (!pages.length) {
box.innerHTML = '<h3>Heatmap</h3><p class="nw-empty">Sobald Besucher zugestimmt haben und Seiten aufgerufen wurden, kannst du hier sehen, wo geklickt und gescrollt wird.</p>'
return
}
if (!heatListening) {
window.addEventListener('message', onHeatMessage)
heatListening = true
}
const big = heatSel.big === 't' ? 't' : 'd'
box.innerHTML = `
<div class="hs-head">
<h3>Heatmap</h3>
<div class="hs-switch" role="group" aria-label="Großes Gerät">${['d', 't'].map((vp) => `<button type="button" class="an-tab${vp === big ? ' active' : ''}" data-vp="${vp}" aria-pressed="${vp === big}">${HEAT_DEVICES[vp].label}</button>`).join('')}</div>
</div>
<p>So sieht deine Website auf den Geräten aus, mit den Klicks der letzten 30 Tage. Scrolle in den Geräten durch die Seite; Links sind hier gesperrt.</p>
<div class="an-heat-row">
<label>Seite<select id="an-heat-page">${pages.map((p) => `<option value="${esc(p.key)}">${esc(p.key || '/')}</option>`).join('')}</select></label>
</div>
<div class="hs-stage" data-big="${big}">
<figure class="hs-dev hs-dev--big"></figure>
<figure class="hs-dev hs-dev--phone"></figure>
</div>`
const stage = box.querySelector('.hs-stage')
const bigFig = box.querySelector('.hs-dev--big')
const phoneFig = box.querySelector('.hs-dev--phone')
const page = () => box.querySelector('#an-heat-page').value || '/'
const show = (fig, vp) => {
fig.heatReloads = 0
loadHeatDevice(fig, projectId, vp, page())
}
box.querySelector('#an-heat-page').addEventListener('change', () => {
show(bigFig, heatSel.big)
show(phoneFig, 'm')
})
box.querySelectorAll('.hs-switch button').forEach((b) => b.addEventListener('click', () => {
if (b.dataset.vp === heatSel.big) return
heatSel.big = b.dataset.vp
box.querySelectorAll('.hs-switch button').forEach((x) => {
const on = x.dataset.vp === heatSel.big
x.classList.toggle('active', on)
x.setAttribute('aria-pressed', String(on))
})
stage.dataset.big = heatSel.big
show(bigFig, heatSel.big)
}))
heatSel.big = big
// Websites erst laden, wenn die Heatmap in Sicht kommt (sie steht unten auf der Seite)
const start = () => {
show(bigFig, heatSel.big)
show(phoneFig, 'm')
}
if (typeof IntersectionObserver !== 'function') return start()
const io = new IntersectionObserver((entries) => {
if (!entries.some((e) => e.isIntersecting)) return
io.disconnect()
start()
}, { rootMargin: '300px 0px' })
io.observe(stage)
}
// Ausschalten mit Bestaetigung in der Seite (zweiter Klick), ohne Browser-Dialog
export function renderAnalyseOff(box, projectId, reload) {
box.innerHTML = `
<h3>Analyse ausschalten</h3>
<p>Danach werden keine neuen Daten mehr gespeichert, und das Banner erscheint nicht mehr. Skript und Zusatzseite entfernen wir mit der nächsten Veröffentlichung Deiner Website, in der Regel sofort. Die bisherigen Daten löschen wir nach 60 Tagen; bis zum 46. Tag kannst Du eine Kopie anfordern.</p>
<p class="error hidden" id="an-off-error" role="alert"></p>
<div class="nw-confirm" id="an-off-actions"><button type="button" class="an-ghost" id="an-off-ask">Analyse ausschalten</button></div>`
const actions = box.querySelector('#an-off-actions')
const error = box.querySelector('#an-off-error')
box.querySelector('#an-off-ask').addEventListener('click', () => {
actions.innerHTML = '<span>Wirklich ausschalten?</span> <button type="button" class="an-ghost an-danger" id="an-off-yes">Ja, ausschalten</button> <button type="button" class="an-ghost" id="an-off-no">Abbrechen</button>'
actions.querySelector('#an-off-no').addEventListener('click', () => renderAnalyseOff(box, projectId, reload))
actions.querySelector('#an-off-yes').addEventListener('click', async (ev) => {
ev.currentTarget.disabled = true
try {
await request('/api/funktionen/analyse/deactivate', { method: 'POST', body: { projectId } })
box.innerHTML = `
<h3>Analyse ausgeschaltet</h3>
<p id="an-off-done">Die Website-Analyse ist ausgeschaltet. Ab jetzt werden keine Daten mehr gespeichert.</p>
<div class="abo-actions"><button type="button" class="abo-btn" id="an-off-ok">OK</button></div>`
box.querySelector('#an-off-ok').addEventListener('click', () => reload())
} catch (err) {
renderAnalyseOff(box, projectId, reload)
const e2 = box.querySelector('#an-off-error')
e2.textContent = err.message
e2.classList.remove('hidden')
}
})
})
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 334 KiB

@@ -54,7 +54,7 @@
<p>(1) Der Kunde schließt diesen Vertrag, wenn er im Kundenportal die Einrichtung des Terminplaners bestellt und dabei per Haken diesem Vertrag und der Leistungsbeschreibung „Terminplaner“ („Leistungsbeschreibung“) in der angezeigten Fassung zustimmt. Das Portal speichert die Fassung, den Zeitpunkt, das Kundenkonto und die angemeldete Person. Anschließend sendet WEBklar dem Kunden eine E-Mail, die beide Texte und die Widerrufsbelehrung in dieser Fassung als PDF enthält und auf sie verlinkt.</p>
<p>(2) Ein Mitarbeiter von WEBklar darf die Funktion nur einschalten, wenn der Kunde vorher in Textform (etwa im Chat des Kundenportals oder per E-Mail) erklärt hat, dass er diesem Vertrag und der Leistungsbeschreibung in der aktuellen Fassung zustimmt. WEBklar speichert die Fassung, das Datum und den Weg dieser Erklärung, den Zeitpunkt des Einschaltens und den Namen des Mitarbeiters. Der Kunde erhält die E-Mail nach Absatz 1 anschließend.</p>
<p>(2) Ein Mitarbeiter von WEBklar darf die Funktion nur einschalten, wenn der Kunde vorher in Textform (etwa im Chat des Kundenportals oder per E-Mail) erklärt hat, dass er diesem Vertrag und der Leistungsbeschreibung in der aktuellen Fassung zustimmt. WEBklar speichert die Fassung, das Datum und den Weg dieser Erklärung, den Zeitpunkt des Einschaltens und den Namen des Mitarbeiters. Der Kunde erhält anschließend eine E-Mail mit diesen beiden Texten als PDF.</p>
<p>(3) Dieser Vertrag ist ein eigener Vertrag für den Terminplaner. Er ergänzt den Auftragsverarbeitungsvertrag in Anlage 1 der Abo-Bedingungen. Für den Terminplaner geht er Anlage 1 vor; im Übrigen gilt Anlage 1 ergänzend. In Datenschutzfragen geht er den Abo-Bedingungen und der Leistungsbeschreibung vor.</p>
@@ -118,7 +118,7 @@
<p>(1) WEBklar leitet Anfragen von Gästen und Beschäftigten, die an WEBklar gerichtet sind, unverzüglich an den Kunden weiter. WEBklar unterstützt ihn mit geeigneten technischen und organisatorischen Maßnahmen bei der Beantwortung.</p>
<p>(2) Dafür bietet das Kundenportal die Aktion „Gastdaten löschen“ und den Export der Termine als CSV-Datei. Mit „Gastdaten löschen“ leert der Kunde Name, E-Mail-Adresse, Telefonnummer, Nachricht und Zusatzfelder einer Buchung; Status und Zeiten bleiben. Noch nicht versandte E-Mails zu dieser Buchung löscht WEBklar dabei, und die Links des Gastes werden ungültig. Eine noch nicht bestätigte Buchung verfällt von selbst (Nummer 9 Abs. 1).</p>
<p>(2) Dafür bietet das Kundenportal die Aktion „Gastdaten löschen“ und den Export der Termine als CSV-Datei. Mit „Gastdaten löschen“ leert der Kunde Name, E-Mail-Adresse, Telefonnummer, Nachricht und Zusatzfelder einer Buchung; Status und Zeiten bleiben. Dabei löscht WEBklar die E-Mails zu dieser Buchung, die noch auf den Versand warten, und die Links des Gastes werden ungültig. E-Mails, deren Versand endgültig fehlgeschlagen ist, behalten Empfängeradresse und Inhalt, bis WEBklar sie nach Nummer 9 Abs. 4 löscht (48 Stunden nach dem Fehlschlag). Eine noch nicht bestätigte Buchung verfällt von selbst (Nummer 9 Abs. 1).</p>
<p>(3) Verletzungen des Schutzes personenbezogener Daten meldet WEBklar dem Kunden unverzüglich, möglichst innerhalb von 24 Stunden nach Kenntnis, mit den Informationen, die zu diesem Zeitpunkt vorliegen.</p>
@@ -140,7 +140,7 @@
<p>(5) Der Löschlauf läuft täglich; gelöscht wird frühestens nach Ablauf der jeweiligen Frist. Ist an einem Tag mehr zu löschen, als ein Lauf schafft, folgt der Rest im nächsten Lauf.</p>
<p>(6) Ist der Terminplaner abgeschaltet oder das Wartungs-Abo beendet, löscht WEBklar alle Termindaten und die Einstellungen 30 Tage danach. Bis dahin kann der Kunde die Termine im Kundenportal als CSV-Datei exportieren. Beginnt innerhalb dieser Frist wieder ein Wartungs-Abo, schaltet WEBklar den Terminplaner mit den vorhandenen Daten und Einstellungen wieder ein (Nr. 8 Abs. 4 der Leistungsbeschreibung). Früher löscht der Kunde einzelne Gastdaten selbst mit der Aktion „Gastdaten löschen“ (Nummer 8 Abs. 2); alle Daten löscht WEBklar auf Weisung des Kunden in Textform unverzüglich.</p>
<p>(6) Ist der Terminplaner abgeschaltet oder das Wartungs-Abo beendet, löscht WEBklar alle Termindaten und die Einstellungen 30 Tage danach. Eine Ausnahme ist das Einrichtungs-Ticket, in dem WEBklar die Einrichtung als Arbeitsnachweis festhält: Es bleibt mit einer Kurzfassung der Einstellungen bestehen (zum Beispiel Betriebsangaben, Namen der Leistungen, die ersten acht Namen der Personen oder Plätze und die Wünsche des Kunden zum Einbau) und enthält keine Daten von Gästen; dafür gilt die Datenschutzerklärung von WEBklar (Abschnitt 6). Bis zur Löschung kann der Kunde die Termine im Kundenportal als CSV-Datei exportieren. Beginnt innerhalb dieser Frist wieder ein Wartungs-Abo, schaltet WEBklar den Terminplaner mit den vorhandenen Daten und Einstellungen wieder ein (Nr. 8 Abs. 4 der Leistungsbeschreibung). Früher löscht der Kunde einzelne Gastdaten selbst mit der Aktion „Gastdaten löschen“ (Nummer 8 Abs. 2); alle Daten löscht WEBklar auf Weisung des Kunden in Textform unverzüglich.</p>
<p>(7) Entwürfe aus dem Fragebogen, aus denen keine Bestellung geworden ist, löscht WEBklar 90 Tage nach der letzten Änderung.</p>
@@ -185,7 +185,7 @@
<tr><td>Missbrauchsschutz</td><td>Ein verstecktes Feld (Honeypot), eine Mindestzeit bis zum Absenden des Formulars mit signiertem Formular-Token und eine Mindestantwortzeit schützen vor automatisierten Buchungen. Grenzen je IP-Adresse, je Website und je E-Mail-Adresse sowie das Mailbudget je Stunde (insgesamt und je Funktion) gelten nur im Arbeitsspeicher; ein Neustart leert sie.</td></tr>
<tr><td>Datenminimierung</td><td>Pflicht sind immer nur Name und E-Mail-Adresse; ob Telefonnummer, Nachricht und Zusatzfelder abgefragt werden, legt der Kunde fest. Mit der Buchung werden keine IP-Adresse und kein Browser-Kennzeichen gespeichert. Der Nachweis der Zustimmungen besteht aus der Fassung der Gästetexte, dem Zeitpunkt, dem Text des Buchungsknopfs, der Anrede und Hashes der übrigen gezeigten Texte, nicht aus diesen Texten. Nach dem Versand einer E-Mail werden Empfängeradresse und Inhalt in der Warteschlange geleert.</td></tr>
<tr><td>Trennung</td><td>Alle Buchungen, Belegungen und Warteschlangeneinträge tragen die Kennung der Funktion (eines Terminplaners je Website-Projekt); das Kundenportal und die öffentliche Schnittstelle lesen immer nur die Daten der jeweiligen Funktion. Die Hintergrundläufe (Löschung, Versand der E-Mails, Erinnerungen) gehen die Dokumente aller Funktionen nacheinander durch und schreiben jeweils nur in das Dokument, um das es geht. Die Einstellungen liegen je Funktion in einem eigenen Dokument.</td></tr>
<tr><td>Löschung</td><td>Täglicher Löschlauf für Buchungen, Belegungen, Warteschlange und Entwürfe nach den Fristen der Nummer 9; Termindaten und Einstellungen abgeschalteter Funktionen 30 Tage nach dem Abschalten. Vor der Löschung einer Buchung, einer abgeschalteten Funktion oder eines nicht bestellten Fragebogens wird der Zustand frisch gelesen; bei einem Lesefehler wird nichts gelöscht.</td></tr>
<tr><td>Löschung</td><td>Täglicher Löschlauf für Buchungen, Belegungen, Warteschlange und Entwürfe nach den Fristen der Nummer 9; Termindaten und Einstellungen abgeschalteter Funktionen 30 Tage nach dem Abschalten; das Einrichtungs-Ticket bleibt als Arbeitsnachweis mit einer Kurzfassung der Einstellungen bestehen und enthält keine Daten von Gästen (Nummer 9 Abs. 6). Vor der Löschung einer Buchung, einer abgeschalteten Funktion oder eines nicht bestellten Fragebogens wird der Zustand frisch gelesen; bei einem Lesefehler wird nichts gelöscht.</td></tr>
<tr><td>Protokolle</td><td>Die Programmprotokolle des Terminplaners und der Tagesbericht enthalten nur Zählungen und Buchungsnummern, keine Namen, E-Mail-Adressen oder Nachrichten von Gästen. Server-Protokolle des Webservers werden nach 14 Tagen gelöscht (Anlage 2 der Abo-Bedingungen).</td></tr>
<tr><td>Datensicherung</td><td>Tägliche Vollsicherung des Servers durch Hetzner; verfügbar sind die letzten sieben Tagessicherungen (§ 3 Abs. 5 der Abo-Bedingungen).</td></tr>
<tr><td>Rechenzentrum</td><td>Hetzner Online GmbH, Rechenzentren Falkenstein und Nürnberg (Deutschland). Hetzner ist nach ISO/IEC 27001 zertifiziert; der Geltungsbereich umfasst die Hosting-Dienstleistungen und die Rechenzentren.</td></tr>
Binary file not shown.
@@ -1 +1 @@
93776bde9b84709dbb6b5065cbf6c31c27080744c8096804d5b6508f9e87c14b
3a10d5b588d05ef9f711fee796157b5c2a9170f39c5d7f2f4f015308d318e9ef
@@ -0,0 +1,197 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="webklar-fassung" content="2026-10-05">
<title>Auftragsverarbeitungsvertrag Webshop – WEBklar</title>
<style>
:root { color-scheme: light; }
* { box-sizing: border-box; }
body { margin: 0; background: #f7f6f3; color: #1d1d1f; font: 16px/1.6 system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif; }
main { max-width: 780px; margin: 0 auto; padding: 44px 20px 80px; }
.brand { font-weight: 800; letter-spacing: 0.12em; font-size: 13px; color: #6d1fd1; }
h1 { font-size: 30px; line-height: 1.2; margin: 6px 0 6px; }
h2 { font-size: 20px; margin: 36px 0 10px; padding-top: 14px; border-top: 1px solid #e3e0da; }
h3 { font-size: 16.5px; margin: 22px 0 6px; }
p, li { margin: 6px 0; }
ul { padding-left: 24px; }
.meta { color: #6b6b70; font-size: 14px; }
.box { background: #fff; border: 1px solid #e3e0da; border-radius: 14px; padding: 16px 20px; margin: 18px 0; }
.box.widerspruch { border: 2px solid #6d1fd1; }
table { border-collapse: collapse; width: 100%; font-size: 15px; }
td, th { padding: 6px 10px 6px 0; border-bottom: 1px solid #eee; vertical-align: top; text-align: left; }
th { font-size: 13px; text-transform: uppercase; letter-spacing: 0.04em; color: #6b6b70; }
a { color: #6d1fd1; }
nav.top { font-size: 14px; margin-bottom: 22px; }
.offen { background: #fff3cd; padding: 0 4px; border-radius: 4px; }
@media print {
@page { size: A4; margin: 18mm 17mm 18mm;
@top-left { content: "WEBklar · AVV Webshop"; font: 8pt sans-serif; color: #888; }
@bottom-right { content: "Seite " counter(page) " von " counter(pages); font: 8pt sans-serif; color: #888; } }
body { background: #fff; font-size: 10pt; line-height: 1.45; }
main { max-width: none; padding: 0; }
nav.top { display: none; }
h1 { font-size: 20pt; }
h2 { font-size: 13pt; margin-top: 16pt; break-after: avoid; }
a { color: inherit; text-decoration: none; }
.box, tr, ol.abs > li { break-inside: avoid; }
}
</style>
</head>
<body>
<main>
<nav class="top"><a href="/dashboard.html">← Kundenportal</a> · <a href="/abo-bedingungen.html">Abo-Bedingungen</a> · <a href="/rechtliches/leistungsbeschreibung-webshop-2026-10-05.html">Leistungsbeschreibung Webshop</a> · <a href="/rechtliches/avv-webshop-2026-10-05.html">AVV Webshop</a> · <a href="/rechtliches/widerrufsbelehrung-funktionen-2026-10-05.html">Widerrufsbelehrung Funktionen</a> · <a href="/datenschutz.html">Datenschutzerklärung</a> · <a href="https://webklar.com/impressum">Impressum</a></nav>
<div class="brand">WEBKLAR</div>
<h1>Auftragsverarbeitungsvertrag Webshop (Art. 28 DSGVO)</h1>
<p class="meta"><strong>Fassung 2026-10-05</strong></p>
<p>zwischen dem Kunden als Verantwortlichem („Kunde“; in der Leistungsbeschreibung „Händler“) und der WEBklar GbR, Am Schwimmbad 10, 67722 Winnweiler, vertreten durch die Gesellschafter Kenso Grimm und Justin Klein, als Auftragsverarbeiter („WEBklar“).</p>
<h2>Vorbemerkung: Abschluss und Verhältnis zu anderen Verträgen</h2>
<p>(1) Der Kunde schließt diesen Vertrag, wenn er im Kundenportal die Einrichtung des Webshops bestellt und dabei per Haken diesem Vertrag und der Leistungsbeschreibung „Webshop“ („Leistungsbeschreibung“) in der angezeigten Fassung zustimmt. Das Portal speichert die Fassung, den Zeitpunkt, das Kundenkonto und die angemeldete Person. Anschließend sendet WEBklar dem Kunden eine E-Mail, die beide Texte und die Widerrufsbelehrung in dieser Fassung als PDF enthält und auf sie verlinkt.</p>
<p>(2) Ein Mitarbeiter von WEBklar darf die Funktion nur einschalten, wenn der Kunde vorher in Textform (etwa im Chat des Kundenportals oder per E-Mail) erklärt hat, dass er diesem Vertrag und der Leistungsbeschreibung in der aktuellen Fassung zustimmt. Das Portal verlangt dafür das Datum und den Weg der Erklärung; fehlt eine Angabe, schaltet es nicht ein. WEBklar speichert vor dem Einschalten einen Zustimmungsnachweis mit der Fassung der beiden Texte, dem Datum und dem Weg dieser Erklärung, dem Zeitpunkt des Einschaltens und dem Namen des Mitarbeiters (ohne IP-Adresse und Browser-Kennzeichen, weil der Kunde nicht im Portal zugestimmt hat). Der Kunde erhält anschließend eine E-Mail mit diesen beiden Texten als PDF.</p>
<p>(3) Dieser Vertrag ist ein eigener Vertrag für den Webshop. Er ergänzt den Auftragsverarbeitungsvertrag in Anlage 1 der Abo-Bedingungen. Für den Webshop geht er Anlage 1 vor; im Übrigen gilt Anlage 1 ergänzend. In Datenschutzfragen geht er den Abo-Bedingungen und der Leistungsbeschreibung vor.</p>
<p>(4) Die Rollen: Der Kunde ist Verantwortlicher für die personenbezogenen Daten seiner Käufer im Webshop. WEBklar ist sein Auftragsverarbeiter. Stripe ist nicht Auftragsverarbeiter von WEBklar, sondern Vertragspartner des Kunden (Nummer 7 Abs. 4).</p>
<h2>1. Gegenstand und Dauer</h2>
<p>(1) WEBklar betreibt für die öffentliche Website des Kunden ein Shop-Widget mit einer Bestellverwaltung im Kundenportal („Webshop“) und verarbeitet dabei personenbezogene Daten der Käufer des Kunden im Auftrag des Kunden. Den Umfang beschreibt die Leistungsbeschreibung.</p>
<p>(2) Der Vertrag beginnt mit dem Einschalten der Funktion. Er endet mit dem Abschalten, spätestens mit dem Ende des Vertrags, aufgrund dessen WEBklar die Website des Kunden betreibt; den Webshop schaltet WEBklar dann zum Vertragsende ab (Nr. 10 Abs. 1 der Leistungsbeschreibung). Die Pflichten aus diesem Vertrag gelten fort, bis alle Daten nach Nummer 9 gelöscht sind.</p>
<h2>2. Art und Zweck der Verarbeitung</h2>
<p>(1) Art: Übermitteln der Bestellung vom Widget an den Server von WEBklar, Übernehmen der Zahlungs- und Adressangaben aus der Checkout-Sitzung bei Stripe, Speichern, Verwalten und Anzeigen im Kundenportal, Versenden von E-Mails, Bereitstellen einer CSV-Datei, Weitergeben von Erstattungen an Stripe im Auftrag des Kunden, Anonymisieren und Löschen.</p>
<p>(2) Zwecke: Der Kunde soll Bestellungen seiner Käufer abwickeln. Dazu nimmt der Webshop Bestellungen entgegen, reserviert den Bestand, gleicht Zahlungen mit Stripe ab, bestätigt Bestellungen (bei einer Zahlung, die noch aussteht, etwa einer SEPA-Lastschrift, zunächst nur mit einer Eingangsmail ohne Vertragsschluss), bildet Versand und Abholung ab, nimmt Widerrufe entgegen, erstattet Zahlungen im Auftrag des Kunden und benachrichtigt den Kunden. WEBklar versendet die E-Mails im Auftrag des Kunden. Außerdem schützt WEBklar den Webshop für den Kunden vor Missbrauch (Mengenbegrenzung, Begrenzung der offenen Bestellungen je IP-Adresse, Erkennen automatisierter Bestellungen und Widerrufe).</p>
<p>(3) WEBklar verwendet die Daten für keine eigenen Zwecke, auch nicht in anonymisierter Form und nicht für Auswertungen über mehrere Kunden. Es findet keine Werbung und keine Zusammenführung mit anderen Daten statt. WEBklar bildet keine Profile einzelner Käufer. Zählungen ohne Personenbezug, die WEBklar für den sicheren Betrieb braucht (etwa die Zahl der anonymisierten Bestellungen je Tag oder die Länge der Versandwarteschlange), sind keine eigenen Zwecke.</p>
<h2>3. Datenarten und Betroffene</h2>
<p>(1) Daten der Käufer, die WEBklar aus der Checkout-Sitzung bei Stripe und aus dem Widget erhält und speichert: Name, E-Mail-Adresse, Lieferadresse und Rechnungsadresse (die Rechnungsadresse nur, soweit die Zahlungsseite von Stripe sie für die gewählte Zahlungsart abfragt); die Bestellung (Bestellnummer, Positionen mit Namen, Mengen und Preisen zum Zeitpunkt der Bestellung, Versandkosten, Lieferart, Steuerausweis, Gesamtbetrag); der Status der Bestellung mit den Zeitpunkten (bestellt, bezahlt, versendet, abholbereit, abgeschlossen, storniert, erstattet); Zahlungsstatus, Zahlungsart (zum Beispiel Karte oder SEPA-Lastschrift) und Kennungen der Zahlung bei Stripe; Versanddienst und Sendungsnummer, wenn der Kunde sie einträgt; Erstattungen mit Betrag, Zeitpunkt und Grund; die Prüfsumme (Hash) der Rechtstexte des Kunden, die der Käufer bei der Bestellung gesehen hat (den Wortlaut der Fassung verwahrt das Archiv nach Nummer 8 Abs. 5); ein Hashwert der IP-Adresse (Absatz 4); der Zustellstatus der E-Mails an den Käufer, auch der Eingangsmail bei ausstehender Zahlung. <strong>Kartendaten und Kontodaten (IBAN) erhält und speichert WEBklar nicht;</strong> die gibt der Käufer nur bei Stripe ein. <strong>Eine Telefonnummer des Käufers erhebt und speichert WEBklar nicht:</strong> Das Widget hat kein Telefonfeld, die Zahlungsseite von Stripe ist so eingestellt, dass sie keine Telefonnummer verlangt, und eine Telefonnummer, die Stripe in den Kundendaten der Sitzung dennoch übermitteln sollte, übernimmt WEBklar nicht in die Bestellung. Die Telefonnummer des Kunden selbst (Anbieterangaben in seinen Einstellungen) gehört nicht zu den Käuferdaten.</p>
<p>(2) Daten von Personen, die über die Widerrufsfunktion einen Vertrag widerrufen (Käufer und Interessenten, auch ohne passende Bestellung): Name, E-Mail-Adresse, Bestellnummer, die Erklärung als Freitext und der Zeitpunkt des Eingangs, außerdem der Zeitpunkt der Eingangsbestätigung und der Bearbeitung durch den Kunden.</p>
<p>(3) Besondere Kategorien personenbezogener Daten (Art. 9 DSGVO) werden nicht verarbeitet. Waren, aus deren Kauf sich besondere Kategorien ergeben können, etwa Arzneimittel, sind nach Nr. 3 der Leistungsbeschreibung ausgeschlossen; der Kunde fordert von seinen Käufern keine entsprechenden Angaben (Nummer 11 Abs. 4).</p>
<p>(4) IP-Adresse und Browser-Kennzeichen (User-Agent) erhält der Server von WEBklar technisch bei jedem Abruf des Widgets und jeder Anfrage. Die Mengenbegrenzung (Missbrauchsschutz) verwendet die IP-Adresse im Arbeitsspeicher des Servers; ein Neustart leert sie. Mit dem Widerruf wird keine IP-Adresse und kein Browser-Kennzeichen gespeichert. Mit der Bestellung werden ebenfalls weder eine IP-Adresse im Klartext noch ein Browser-Kennzeichen gespeichert, sondern nur ein <strong>Hashwert der IP-Adresse („IP-Hash“)</strong>: die ersten 16 Hexadezimalzeichen eines SHA-256-Werts, den WEBklar mit einem geheimen Schlüssel des Servers salzt. Der IP-Hash ist ein pseudonymes technisches Datum und bleibt ein personenbezogenes Datum, weil WEBklar mit dem Schlüssel für dieselbe IP-Adresse denselben Wert berechnen kann; ohne den Schlüssel lässt sich aus ihm keine IP-Adresse ableiten. WEBklar verwendet ihn ausschließlich zum Missbrauchsschutz: Je IP-Hash und Webshop sind höchstens drei offene (noch nicht bezahlte) Bestellungen innerhalb von 30 Minuten zulässig. Er dient nicht dazu, Käufer zu identifizieren, wiederzuerkennen oder zu profilieren, wird dem Kunden weder angezeigt noch exportiert und steht nur im Datensatz der Bestellung. WEBklar löscht ihn aus dem Datensatz der Bestellung, sobald die Bestellung bezahlt oder storniert wird, spätestens mit der Löschung nicht bezahlter Bestellungen nach 30 Tagen (Nummer 9 Abs. 1); bei bezahlten Bestellungen bleibt er nicht bis zur Anonymisierung (Nummer 9 Abs. 2) erhalten. In den Server-Protokollen stehen IP-Adresse und Browser-Kennzeichen 14 Tage (Anlage 2 der Abo-Bedingungen).</p>
<p>(5) Der Webshop setzt keine Cookies. Das Widget speichert im Browser des Besuchers nur den Warenkorb (Kennungen der Artikel und Mengen, ohne Personenbezug) im Sitzungsspeicher (sessionStorage) des Tabs, solange der Tab geöffnet ist; das ist für den Warenkorb technisch erforderlich. Andere Daten legt das Widget im Browser nicht ab.</p>
<p>(6) Betroffene: Käufer, die über die Website des Kunden Waren bestellen, und Personen, die über die Widerrufsfunktion einen Vertrag widerrufen. Beschäftigte des Kunden sind von diesem Vertrag nicht betroffen.</p>
<h2>4. Weisungen</h2>
<p>(1) WEBklar verarbeitet die Daten nur auf dokumentierte Weisung des Kunden, auch in Bezug auf eine Übermittlung in ein Drittland oder an eine internationale Organisation. Das gilt nicht, wenn das Recht der Europäischen Union oder Deutschlands WEBklar zu einer Verarbeitung verpflichtet. In diesem Fall teilt WEBklar dem Kunden die Pflicht vorher mit, soweit das Gesetz es nicht verbietet.</p>
<p>(2) Mit der Bestellung weist der Kunde WEBklar an, den Webshop nach seinen Einstellungen und der Leistungsbeschreibung zu betreiben. Weitere Weisungen ergeben sich aus den Einstellungen und Aktionen im Kundenportal (Artikel, Versand, Abholung, Zahlungsarten, Rechtstexte, Bestellstatus, Erstattungen, Öffnen und Schließen des Webshops, Export). Sonstige Weisungen erteilt der Kunde in Textform, etwa im Chat des Kundenportals. Weisungen, die über diese Einstellungen hinausgehen, setzt WEBklar nur um, wenn das technisch vorgesehen ist; sonst kann der Kunde den Webshop abschalten lassen (Nr. 10 Abs. 7 der Leistungsbeschreibung). Die Anonymisierung und Löschung auf Weisung nach Nummer 8 Abs. 2 und Nummer 9 Abs. 6 setzt WEBklar immer um.</p>
<p>(3) Hält WEBklar eine Weisung für rechtswidrig, weist es den Kunden unverzüglich darauf hin. WEBklar darf die Ausführung aussetzen, bis der Kunde die Weisung ändert oder die Rechtslage geklärt ist.</p>
<h2>5. Vertraulichkeit</h2>
<p>WEBklar setzt nur Personen ein, die zur Vertraulichkeit verpflichtet sind oder einer gesetzlichen Verschwiegenheitspflicht unterliegen.</p>
<h2>6. Technische und organisatorische Maßnahmen</h2>
<p>(1) WEBklar trifft die Maßnahmen nach Art. 32 DSGVO, die in der Anlage zu diesem Vertrag beschrieben sind. WEBklar darf sie an den Stand der Technik anpassen, sofern das Schutzniveau nicht sinkt.</p>
<p>(2) Für den Server gelten ergänzend die Maßnahmen der Anlage 2 der Abo-Bedingungen, soweit die Anlage zu diesem Vertrag nichts anderes regelt.</p>
<h2>7. Unterauftragsverarbeiter</h2>
<p>(1) Der Kunde genehmigt den Einsatz der Hetzner Online GmbH, Industriestraße 25, 91710 Gunzenhausen, für den Betrieb des Servers, auf dem der Webshop läuft, und für dessen Sicherung (Rechenzentrum Falkenstein, Deutschland) sowie für den Versand der Bestell-E-Mails über den SMTP-Dienst des Webhostings (Rechenzentrum Nürnberg, Deutschland). Eine Übermittlung in Drittländer findet nicht statt.</p>
<p>(2) Will WEBklar einen weiteren Unterauftragsverarbeiter hinzuziehen oder einen ersetzen, teilt es das dem Kunden mindestens vier Wochen vorher in Textform mit. Der Kunde kann innerhalb von zwei Wochen nach Zugang der Mitteilung aus wichtigem datenschutzrechtlichem Grund in Textform widersprechen. WEBklar setzt den neuen Unterauftragsverarbeiter nicht vor Ablauf dieser Frist ein. Kündigt ein Unterauftragsverarbeiter von WEBklar eine eigene Änderung mit kürzerer Frist an, informiert WEBklar den Kunden unverzüglich, nachdem die Ankündigung eingegangen ist; der Kunde kann dann unverzüglich widersprechen. Einigen sich die Parteien nicht, kann der Kunde den Webshop abschalten lassen und WEBklar ihn für diese Website zu dem Zeitpunkt beenden, zu dem der neue Unterauftragsverarbeiter eingesetzt wird. Die Daten werden dann nach Nummer 9 gelöscht, auf Weisung des Kunden früher (Nummer 9 Abs. 6).</p>
<p>(3) WEBklar verpflichtet jeden Unterauftragsverarbeiter vertraglich auf die Datenschutzpflichten nach Art. 28 Abs. 4 DSGVO. Bei der Hetzner Online GmbH ist das deren Vertrag zur Auftragsverarbeitung, den WEBklar abgeschlossen hat. Kommt ein Unterauftragsverarbeiter seinen Datenschutzpflichten nicht nach, haftet WEBklar dem Kunden für die Einhaltung dieser Pflichten (Art. 28 Abs. 4 Satz 2 DSGVO).</p>
<p>(4) <strong>Stripe ist kein Unterauftragsverarbeiter von WEBklar.</strong> Der Kunde verwendet sein eigenes Stripe-Konto und hat den Vertrag darüber unmittelbar mit Stripe geschlossen (Nr. 4 der Leistungsbeschreibung). Die Zahlungsseite (Stripe Checkout), die Erhebung von Zahlungs-, Karten- und Kontodaten, die Prüfung von Zahlungen und die Auszahlungen verantworten der Kunde und Stripe nach ihrem Vertrag; WEBklar erhält diese Daten nicht. Auf Weisung des Kunden ruft WEBklar bei Stripe die Angaben zu Zahlungen und Bestellungen ab, die der Webshop braucht (Nummer 2 Abs. 1), und veranlasst Erstattungen. Daten von Käufern gehen dabei nicht von WEBklar an Stripe, außer der Erstattung selbst und der Kennung der Zahlung. Die Weitergabe von Daten durch Stripe, auch an Stellen außerhalb der Europäischen Union, regeln der Kunde und Stripe; dafür ist WEBklar nicht verantwortlich.</p>
<h2>8. Unterstützung des Kunden, Betroffenenrechte</h2>
<p>(1) WEBklar leitet Anfragen von Käufern, die an WEBklar gerichtet sind, unverzüglich an den Kunden weiter. WEBklar unterstützt ihn mit geeigneten technischen und organisatorischen Maßnahmen bei der Beantwortung.</p>
<p>(2) Dafür bietet das Kundenportal den Export der Bestellungen als CSV-Datei und die Anzeige der Bestellungen und Widerrufe, mit denen der Kunde Auskunft erteilen kann. Auf Weisung des Kunden in Textform (etwa im Chat des Kundenportals) anonymisiert WEBklar die Käuferdaten einer Bestellung oder eines Widerrufs unverzüglich: Name, E-Mail-Adresse, Liefer- und Rechnungsadresse und die Freitexte werden gelöscht oder unkenntlich gemacht (ein IP-Hash ist zu diesem Zeitpunkt längst gelöscht); Bestellnummer, Positionen, Beträge und Status bleiben. Dabei löscht WEBklar die E-Mails zu dieser Bestellung, die noch auf den Versand warten. E-Mails, deren Versand endgültig fehlgeschlagen ist, behalten Empfängeradresse und Inhalt, bis WEBklar sie nach Nummer 9 Abs. 4 löscht (48 Stunden nach dem Fehlschlag). Gesetzliche Aufbewahrungspflichten des Kunden (Nummer 9 Abs. 8) prüft der Kunde vor seiner Weisung selbst.</p>
<p>(3) Verletzungen des Schutzes personenbezogener Daten meldet WEBklar dem Kunden unverzüglich, möglichst innerhalb von 24 Stunden nach Kenntnis, mit den Informationen, die zu diesem Zeitpunkt vorliegen.</p>
<p>(4) WEBklar unterstützt den Kunden bei seinen Pflichten aus Art. 32 bis 36 DSGVO. Dazu gehören die Angaben, die er für sein Verzeichnis von Verarbeitungstätigkeiten und für eine etwaige Datenschutz-Folgenabschätzung braucht.</p>
<p>(5) Bestätigt der Kunde seine Rechtstexte im Kundenportal (Allgemeine Geschäftsbedingungen, Widerrufsbelehrung, Muster-Widerrufsformular, Versand und Zahlung, Gewährleistungshinweis sowie die Adressen von Impressum und Datenschutzerklärung), legt WEBklar jede bestätigte Fassung einmal im Archiv der Rechtstexte ab: mit ihrer Prüfsumme (Hash), dem Zeitpunkt und der Angabe, welche angemeldete Person des Kunden (Name oder E-Mail-Adresse) sie bestätigt hat. Die Bestellung speichert die Prüfsumme der Fassung, die der Käufer bei der Bestellung gesehen hat. Die Bestellbestätigung (G09) zeigt die archivierte Fassung zu dieser Prüfsumme, nicht die Fassung, die beim Versand der E-Mail gerade gilt; fehlt die archivierte Fassung ausnahmsweise, verwendet WEBklar die aktuell bestätigte Fassung. Das Archiv enthält keine Daten von Käufern. WEBklar löscht es mit den übrigen Daten des Webshops (Nummer 9 Abs. 6), nicht früher. Das Archiv hilft dem Kunden, die Fassung nachzuweisen, die ein Käufer erhalten hat; es ersetzt nicht seine eigene Aufbewahrung (Nummer 9 Abs. 8). Die Bestellbestätigung enthält diese Texte; WEBklar löscht ihren Inhalt nach dem Versand aus der Versandwarteschlange (Nummer 9 Abs. 4).</p>
<p>(6) Die Käufer informiert der Kunde (Art. 13 DSGVO; Nummer 11 Abs. 2).</p>
<h2>9. Löschung und Rückgabe</h2>
<p>(1) Eine Bestellung, die nicht bezahlt wird, verfällt 30 Minuten nach dem Anlegen und gilt als storniert. WEBklar löscht nicht bezahlte Bestellungen (offen oder storniert) 30 Tage nach dem Anlegen. Den IP-Hash löscht WEBklar aus dem Datensatz, sobald die Bestellung bezahlt oder storniert wird, spätestens mit dieser Löschung.</p>
<p>(2) Bei bezahlten Bestellungen anonymisiert WEBklar die Käuferdaten 3 Jahre nach dem Ende des Kalenderjahres, in dem die Bestellung angelegt wurde (Bestelljahr; maßgeblich ist die Zeit in Berlin): Name, E-Mail-Adresse sowie Liefer- und Rechnungsadresse werden gelöscht (der IP-Hash ist mit der Zahlung bereits gelöscht; eine Telefonnummer des Käufers wird nicht gespeichert). Bestellnummer, Positionen, Beträge, Steuerausweis, Status, Versanddienst und Sendungsnummer bleiben für die Buchhaltung des Kunden erhalten.</p>
<p>(3) Widerrufe nach Nummer 3 Abs. 2 behandelt WEBklar wie bezahlte Bestellungen: Name, E-Mail-Adresse und Erklärung werden 3 Jahre nach dem Ende des Kalenderjahres des Eingangs gelöscht; die Bestellnummer, die die Person angegeben hat, und der Zeitpunkt des Eingangs bleiben.</p>
<p>(4) In der Versandwarteschlange für E-Mails leert WEBklar die Empfängeradresse und den Inhalt einer E-Mail nach dem Versand und löscht den Eintrag 7 Tage nach dem Versand. E-Mails, die nicht zugestellt werden können, löscht WEBklar mit Empfängeradresse und Inhalt 48 Stunden nach dem endgültigen Fehlschlag.</p>
<p>(5) Der Löschlauf läuft täglich; gelöscht oder anonymisiert wird frühestens nach Ablauf der jeweiligen Frist. Ist an einem Tag mehr zu bearbeiten, als ein Lauf schafft, folgt der Rest im nächsten Lauf.</p>
<p>(6) Ist der Webshop abgeschaltet oder der Vertrag über den Betrieb der Website beendet, kann der Kunde die Bestellungen und Widerrufe 90 Tage lang im Kundenportal lesen und als CSV-Datei exportieren. Danach löscht WEBklar alle Daten des Webshops: Artikel, Bilder, Bestellungen, Widerrufe, Einstellungen, Rechtstexte und das Archiv der bestätigten Fassungen der Rechtstexte (Nummer 8 Abs. 5). Eine Ausnahme ist das Einrichtungs-Ticket, in dem WEBklar die Einrichtung als Arbeitsnachweis festhält: Es bleibt mit einer Kurzfassung der Angaben zur Einrichtung bestehen (zum Beispiel Adresse der Website, Einbau-Code und die Angaben des Kunden aus der Anfrage) und enthält keine Daten von Käufern; dafür gilt die Datenschutzerklärung von WEBklar (Abschnitt 6). Beginnt innerhalb dieser Frist wieder ein Vertrag über den Betrieb der Website, schaltet WEBklar den Webshop auf Wunsch des Kunden mit den vorhandenen Daten wieder ein (Nr. 10 Abs. 5 der Leistungsbeschreibung). Früher anonymisiert WEBklar einzelne Daten auf Weisung des Kunden (Nummer 8 Abs. 2); alle Daten des Webshops löscht WEBklar auf Weisung des Kunden in Textform unverzüglich.</p>
<p>(7) Die Daten sind in der täglichen Vollsicherung des Servers enthalten (§ 3 Abs. 5 der Abo-Bedingungen). Gelöschte oder anonymisierte Daten bleiben dort bis zu sieben Tage erhalten und werden dann überschrieben. Wird eine Sicherung zurückgespielt, holt der tägliche Löschlauf die fälligen Löschungen und Anonymisierungen nach; Löschungen auf Weisung des Kunden wiederholt WEBklar.</p>
<p>(8) Gesetzliche Aufbewahrungspflichten bleiben unberührt. Der Kunde ist für seine eigenen Aufbewahrungspflichten (zum Beispiel für Buchungsbelege) selbst verantwortlich; WEBklar ist kein Archiv im Sinne der Buchführung, und das Archiv der Rechtstexte dient nur dem Nachweis der Texte. Der Kunde sichert die Belege, die er braucht, rechtzeitig vor der Anonymisierung oder Löschung durch den Export und die Belege von Stripe.</p>
<h2>10. Nachweise und Kontrollen</h2>
<p>WEBklar stellt dem Kunden alle Informationen zur Verfügung, die zum Nachweis der Pflichten aus Art. 28 DSGVO nötig sind, und ermöglicht Überprüfungen einschließlich Inspektionen durch den Kunden oder einen von ihm beauftragten, zur Verschwiegenheit verpflichteten Prüfer. Sie finden nach Anmeldung mit angemessener Frist (in der Regel 14 Tage) zu den Servicezeiten statt, ohne den Betrieb unzumutbar zu stören. Für das Rechenzentrum genügen in der Regel die Nachweise des Betreibers (Zertifizierung der Hetzner Online GmbH nach ISO/IEC 27001). Für mehr als eine Vor-Ort-Kontrolle pro Jahr kann WEBklar eine angemessene Vergütung verlangen.</p>
<h2>11. Pflichten des Kunden</h2>
<p>(1) Der Kunde ist Verantwortlicher für die Verarbeitung der Daten seiner Käufer im Webshop.</p>
<p>(2) Der Kunde informiert seine Käufer nach Art. 13 DSGVO. Dazu nimmt er den Datenschutz-Absatz für den Webshop in seine Datenschutzerklärung auf (Nr. 5 Abs. 10 der Leistungsbeschreibung) und nennt in seinen Rechtstexten (Datenschutzerklärung, Impressum) die Angaben, die das Gesetz verlangt.</p>
<p>(3) Der Kunde schließt mit Stripe den Vertrag über sein Stripe-Konto selbst und verantwortet die Verarbeitung von Daten durch Stripe, soweit sie nicht in diesem Vertrag geregelt ist (Nummer 7 Abs. 4).</p>
<p>(4) Der Kunde verkauft keine ausgeschlossenen Waren (Nr. 3 der Leistungsbeschreibung) und fordert von seinen Käufern keine Angaben zu ihrer Gesundheit oder zu anderen besonderen Kategorien personenbezogener Daten, auch nicht im Freitext der Widerrufserklärung.</p>
<p>(5) Der Kunde verändert den Einbau-Code und die gesetzlich vorgeschriebenen Hinweise des Widgets nicht und entfernt den Link „Vertrag widerrufen“ nicht (Nr. 5 Abs. 6 und 9 der Leistungsbeschreibung).</p>
<h2>12. Haftung, Änderungen, Schlussbestimmungen</h2>
<p>(1) Gegenüber betroffenen Personen gilt Art. 82 DSGVO. Für die Haftung zwischen Kunde und WEBklar gilt § 13 der Abo-Bedingungen.</p>
<p>(2) Eine neue Fassung dieses Vertrags oder der Leistungsbeschreibung, auch wegen geänderter Datenerhebung, teilt WEBklar dem Kunden mindestens sechs Wochen vor ihrem Wirksamwerden in Textform mit. Sie wird wirksam, wenn der Kunde ihr im Kundenportal oder in Textform zustimmt. Stimmt er nicht zu, gilt die bisherige Fassung weiter.</p>
<p>(3) Ist die neue Fassung nötig, weil ein Gesetz, eine Gerichtsentscheidung oder eine Vorgabe einer Aufsichtsbehörde den Betrieb in der bisherigen Form nicht mehr zulässt, und stimmt der Kunde ihr nicht zu, darf WEBklar den Webshop für seine Website nach Nr. 11 Abs. 2 der Leistungsbeschreibung beenden oder nach Nr. 11 Abs. 3 der Leistungsbeschreibung aussetzen. Das Abo bleibt davon unberührt.</p>
<p>(4) Im Übrigen gilt § 16 der Abo-Bedingungen.</p>
<h2>Anlage: Technische und organisatorische Maßnahmen des Webshops (Art. 32 DSGVO)</h2>
<table>
<thead><tr><th>Bereich</th><th>Maßnahmen</th></tr></thead>
<tbody>
<tr><td>Übertragung</td><td>Das Widget, die Seiten der Käufer und das Kundenportal werden nur über TLS (HTTPS) übertragen. Der Einbau-Code verweist auf eine Adresse mit https://. Die Zahlung geschieht auf der Zahlungsseite von Stripe.</td></tr>
<tr><td>Speicher im Browser</td><td>Es werden keine Cookies gesetzt. Das Widget speichert nur den Warenkorb (Kennungen der Artikel und Mengen) im Sitzungsspeicher des Tabs. Es sendet seine Anfragen ohne Cookies und ruft nur die Schnittstelle des Webshops auf, keine Dienste Dritter.</td></tr>
<tr><td>Zugriffskontrolle</td><td>Das Kundenportal ist nur nach Anmeldung erreichbar; jeder Kunde sieht nur die Daten seiner eigenen Projekte. Die öffentliche Schnittstelle des Widgets liefert nur für einen vorhandenen Webshop Daten und ist über den Website-Schlüssel (Site-Key) adressiert; Bestellungen und Widerrufe nimmt sie nur von Seiten des Kunden und des Portals an. Den Stand einer Bestellung ruft der Käufer nur mit einem geheimen Token ab, das nur als Prüfsumme gespeichert wird. Benachrichtigungen von Stripe werden nur nach Prüfung ihrer Signatur verarbeitet; die Schlüssel für Stripe liegen nur auf dem Server.</td></tr>
<tr><td>Missbrauchsschutz</td><td>Ein verstecktes Feld (Honeypot), eine Mindestzeit bis zum Absenden des Formulars mit signiertem Formular-Token und eine Mindestantwortzeit schützen vor automatisierten Bestellungen und Widerrufen. Grenzen je IP-Adresse und je Webshop gelten im Arbeitsspeicher; ein Neustart leert sie. Die Obergrenze offener Bestellungen je IP-Adresse (drei in 30 Minuten) arbeitet mit einem gesalzenen Hashwert der IP-Adresse in der Bestellung (Nummer 3 Abs. 4).</td></tr>
<tr><td>Datenminimierung</td><td>Kartendaten und Kontodaten erhält und speichert WEBklar nicht. Preise berechnet der Server aus den gespeicherten Artikeln, nie aus Angaben des Browsers. Eine Telefonnummer wird weder abgefragt noch gespeichert, die Rechnungsadresse nur, soweit Stripe sie für die Zahlungsart braucht. Mit der Bestellung und mit dem Widerruf werden keine IP-Adresse im Klartext und kein Browser-Kennzeichen gespeichert, bei der Bestellung nur der gesalzene IP-Hash, und der nur, solange die Bestellung offen ist (gelöscht mit Zahlung oder Stornierung). Die Käuferdaten werden nach den Fristen der Nummer 9 anonymisiert. Nach dem Versand einer E-Mail werden Empfängeradresse und Inhalt in der Warteschlange geleert.</td></tr>
<tr><td>Trennung</td><td>Alle Artikel, Bestellungen und Widerrufe tragen die Kennung des Webshops und des Kunden; das Kundenportal und die öffentliche Schnittstelle lesen immer nur die Daten des jeweiligen Webshops. Artikelbilder liegen in einem eigenen Ordner je Webshop. Die Hintergrundläufe (Abgleich offener Bestellungen, Versand der E-Mails, Anonymisierung und Löschung) schreiben jeweils nur in das Dokument, um das es geht.</td></tr>
<tr><td>Löschung</td><td>Täglicher Löschlauf für Bestellungen, Widerrufe und Warteschlange nach den Fristen der Nummer 9; die Daten abgeschalteter Webshops einschließlich des Archivs der bestätigten Rechtstexte 90 Tage nach dem Abschalten; das Einrichtungs-Ticket bleibt als Arbeitsnachweis mit einer Kurzfassung der Angaben zur Einrichtung bestehen und enthält keine Daten von Käufern (Nummer 9 Abs. 6).</td></tr>
<tr><td>Protokolle</td><td>Die Programmprotokolle des Webshops und der Tagesbericht enthalten nur Zählungen und Bestellnummern, keine Namen, E-Mail-Adressen oder Adressen von Käufern. Server-Protokolle des Webservers werden nach 14 Tagen gelöscht (Anlage 2 der Abo-Bedingungen).</td></tr>
<tr><td>Datensicherung</td><td>Tägliche Vollsicherung des Servers durch Hetzner; verfügbar sind die letzten sieben Tagessicherungen (§ 3 Abs. 5 der Abo-Bedingungen).</td></tr>
<tr><td>Rechenzentrum</td><td>Hetzner Online GmbH, Rechenzentren Falkenstein und Nürnberg (Deutschland). Hetzner ist nach ISO/IEC 27001 zertifiziert; der Geltungsbereich umfasst die Hosting-Dienstleistungen und die Rechenzentren.</td></tr>
<tr><td>Organisation</td><td>Verpflichtung aller Beteiligten auf Vertraulichkeit, Ablauf für Datenschutzverletzungen (Nummer 8 Abs. 3), Überprüfung der Maßnahmen mindestens einmal im Jahr.</td></tr>
</tbody>
</table>
</main>
</body>
</html>
Binary file not shown.
@@ -0,0 +1 @@
a4a03848c62ad2e9e0f9919949a844337fe631508726cd191f66b6c5b33c89b6
@@ -136,7 +136,7 @@
<p>(2) Mit dem Abschalten nimmt der Terminplaner keine neuen Buchungen mehr an, und das Widget zeigt den Ausweichtext. Die Termine bleiben im Kundenportal sichtbar, und der Kunde kann sie als CSV-Datei exportieren. Gäste können über ihren Link absagen. Bereits angeforderte Erinnerungen laufen weiter. Der Kunde informiert seine Gäste.</p>
<p>(3) WEBklar löscht alle Termindaten und die Einstellungen 30 Tage nach dem Abschalten.</p>
<p>(3) WEBklar löscht alle Termindaten und die Einstellungen 30 Tage nach dem Abschalten. Eine Ausnahme ist das Einrichtungs-Ticket, in dem WEBklar die Einrichtung als Arbeitsnachweis festhält: Es bleibt mit einer Kurzfassung der Einstellungen bestehen und enthält keine Daten von Gästen (Nummer 9 Abs. 6 des AVV Terminplaner).</p>
<p>(4) Beginnt innerhalb dieser 30 Tage wieder ein Wartungs-Abo, schaltet WEBklar den Terminplaner mit den vorhandenen Daten und Einstellungen ohne weitere Kosten wieder ein. Nach der Löschung ist eine neue Einrichtung nötig (neuer Fragebogen, neuer Preis nach Nr. 5).</p>
@@ -1 +1 @@
68039f8293eec7128135e96060b8e04f7e6301de123e099c180feb86530f39f7
da26e937921ef71a5d874551fd085ce63feb9b8663e37571f1a901e75b859473
@@ -0,0 +1,239 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="webklar-fassung" content="2026-10-05">
<title>Leistungsbeschreibung Webshop – WEBklar</title>
<style>
:root { color-scheme: light; }
* { box-sizing: border-box; }
body { margin: 0; background: #f7f6f3; color: #1d1d1f; font: 16px/1.6 system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif; }
main { max-width: 780px; margin: 0 auto; padding: 44px 20px 80px; }
.brand { font-weight: 800; letter-spacing: 0.12em; font-size: 13px; color: #6d1fd1; }
h1 { font-size: 30px; line-height: 1.2; margin: 6px 0 6px; }
h2 { font-size: 20px; margin: 36px 0 10px; padding-top: 14px; border-top: 1px solid #e3e0da; }
h3 { font-size: 16.5px; margin: 22px 0 6px; }
p, li { margin: 6px 0; }
ul { padding-left: 24px; }
.meta { color: #6b6b70; font-size: 14px; }
.box { background: #fff; border: 1px solid #e3e0da; border-radius: 14px; padding: 16px 20px; margin: 18px 0; }
.box.widerspruch { border: 2px solid #6d1fd1; }
table { border-collapse: collapse; width: 100%; font-size: 15px; }
td, th { padding: 6px 10px 6px 0; border-bottom: 1px solid #eee; vertical-align: top; text-align: left; }
th { font-size: 13px; text-transform: uppercase; letter-spacing: 0.04em; color: #6b6b70; }
a { color: #6d1fd1; }
nav.top { font-size: 14px; margin-bottom: 22px; }
.offen { background: #fff3cd; padding: 0 4px; border-radius: 4px; }
@media print {
@page { size: A4; margin: 18mm 17mm 18mm;
@top-left { content: "WEBklar · Leistungsbeschreibung Webshop"; font: 8pt sans-serif; color: #888; }
@bottom-right { content: "Seite " counter(page) " von " counter(pages); font: 8pt sans-serif; color: #888; } }
body { background: #fff; font-size: 10pt; line-height: 1.45; }
main { max-width: none; padding: 0; }
nav.top { display: none; }
h1 { font-size: 20pt; }
h2 { font-size: 13pt; margin-top: 16pt; break-after: avoid; }
a { color: inherit; text-decoration: none; }
.box, tr, ol.abs > li { break-inside: avoid; }
}
</style>
</head>
<body>
<main>
<nav class="top"><a href="/dashboard.html">← Kundenportal</a> · <a href="/abo-bedingungen.html">Abo-Bedingungen</a> · <a href="/rechtliches/leistungsbeschreibung-webshop-2026-10-05.html">Leistungsbeschreibung Webshop</a> · <a href="/rechtliches/avv-webshop-2026-10-05.html">AVV Webshop</a> · <a href="/rechtliches/widerrufsbelehrung-funktionen-2026-10-05.html">Widerrufsbelehrung Funktionen</a> · <a href="/datenschutz.html">Datenschutzerklärung</a> · <a href="https://webklar.com/impressum">Impressum</a></nav>
<div class="brand">WEBKLAR</div>
<h1>Leistungsbeschreibung Webshop</h1>
<p class="meta"><strong>Fassung 2026-10-05</strong> · Zusatzbedingungen zum Vertrag über den Betrieb der Website</p>
<h2>1. Geltung</h2>
<p>(1) Diese Leistungsbeschreibung regelt die Funktion „Webshop“. Mit dem Webshop verkauft der Kunde Waren über seine Website („Händler“); Käufer sind die Besucher seiner Website, die im Webshop bestellen („Käufer“). Die Leistungsbeschreibung ist eine Zusatzleistung zum Vertrag über den Betrieb der Website: Sie setzt voraus, dass WEBklar die Website des Händlers aufgrund eines Vertrags betreibt, etwa im Hosting-Abo, im Wartungs-Abo oder aufgrund eines anderen Vertrags über den Betrieb der Website. Sie richtet sich nur an Unternehmer, die in eigenem Namen und für eigene Rechnung verkaufen.</p>
<p>(2) Sie wird vereinbart, wenn der Händler die Einrichtung des Webshops im Kundenportal bestellt und dabei dieser Leistungsbeschreibung, dem Auftragsverarbeitungsvertrag Webshop („AVV Webshop“) und der Widerrufsbelehrung zustimmt. Wie die Zustimmung abläuft, regelt die Vorbemerkung des AVV Webshop. Bestellen kann der Händler erst, nachdem WEBklar seine Anfrage für den Webshop geprüft und freigegeben hat (Nr. 2 Abs. 1).</p>
<p>(3) Im Übrigen gelten die Abo-Bedingungen. Bei Widersprüchen geht für den Webshop diese Leistungsbeschreibung vor, in Datenschutzfragen der AVV Webshop.</p>
<p>(4) Leistungsbeschreibung A Nr. 2 Abs. 3 und Nr. 4 sowie Leistungsbeschreibung B Nr. 3 der Abo-Bedingungen (Formulare mit Server, „Rechtstexte … nicht enthalten“) stehen dem Webshop nicht entgegen. Das Widget des Webshops läuft auf dem Server von WEBklar, nicht auf dem Server der Website, und die Muster-Rechtstexte nach Nr. 6 sind Teil des Webshops. Das gilt auch im Hosting-Abo.</p>
<h2>2. Leistung</h2>
<p>(1) Der Händler fragt den Webshop im Kundenportal an und beschreibt, was er verkauft. WEBklar prüft die Anfrage, vor allem nach Nr. 3, und gibt sie frei oder lehnt sie mit einer Begründung ab. Ein Anspruch auf Freigabe besteht nicht. Erst nach der Freigabe kann der Händler die Einrichtung bestellen.</p>
<p>(2) Die Einrichtung umfasst den Einbau eines Shop-Widgets an einer Stelle der Website des Händlers, die Seite des Webshops mit einem Verweis im Menü und den Link „Vertrag widerrufen“ in der Fußzeile der Seiten der Website, außerdem die Freischaltung durch WEBklar. Für jede Website wird ein Webshop eingerichtet. Zusätzlich steht dem Händler eine eigenständige Seite des Webshops bei WEBklar zur Verfügung (Standalone-Seite), etwa als Vorschau.</p>
<p>(3) Im Kundenportal verwaltet der Händler:</p>
<ul>
<li>Artikel mit Beschreibung, Bildern, Preis, Varianten, Bestand und den Pflichtangaben zum Artikel,</li>
<li>Versand (nur nach Deutschland und Österreich) und Abholung mit Versandkosten und Lieferzeiten,</li>
<li>Bestellungen mit Status (zum Beispiel bezahlt, versendet, abholbereit, abgeschlossen), die er als CSV-Datei exportieren kann,</li>
<li>Erstattungen, ganz oder teilweise, über sein Stripe-Konto (Nr. 4),</li>
<li>die Widerrufe, die Käufer über die Widerrufsfunktion erklärt haben,</li>
<li>die Verbindung mit seinem Stripe-Konto, seine Einstellungen und seine Rechtstexte, deren bestätigte Fassungen WEBklar archiviert (Nr. 6 Abs. 5).</li>
</ul>
<p>(4) Der Webshop enthält die Widerrufsfunktion nach § 356a BGB: Das Widget zeigt in seiner Fußzeile und die Standalone-Seite zeigt unter einer eigenen Adresse die Schaltfläche „Vertrag widerrufen“. Sie führt zu einem Formular und danach zur Bestätigungsschaltfläche „Widerruf bestätigen“. WEBklar speichert den Eingang, sendet dem Erklärenden sofort eine Eingangsbestätigung mit Inhalt, Datum und Uhrzeit und benachrichtigt den Händler. Die Funktion ist ohne Anmeldung erreichbar, auch wenn zur Erklärung keine Bestellung gefunden wird. Der Händler bearbeitet jeden Widerruf selbst (Nr. 5 Abs. 6).</p>
<p>(5) Der Webshop versendet im Namen des Händlers E-Mails an die Käufer: Eingang der Bestellung (bei einer Zahlung, die noch aussteht, etwa einer SEPA-Lastschrift, vor dem Vertragsschluss), Bestellbestätigung, Versand, Abholbereitschaft, Eingangsbestätigung eines Widerrufs, Erstattung und Hinweis auf eine fehlgeschlagene Zahlung. Absender ist die Versandadresse von WEBklar mit dem Namen des Shops, als Antwortadresse dient die E-Mail-Adresse des Händlers. Den Händler benachrichtigt der Webshop über neue Bestellungen und eingegangene Widerrufe.</p>
<p>(6) Der Webshop enthält einen Missbrauchsschutz gegen automatisierte Bestellungen und Widerrufe, darunter eine Obergrenze von drei offenen Bestellungen je IP-Adresse in 30 Minuten. Dafür speichert WEBklar mit der offenen Bestellung nur einen Hashwert der IP-Adresse, nicht die IP-Adresse selbst; er wird gelöscht, sobald die Bestellung bezahlt oder storniert wird (Nummer 3 Abs. 4 des AVV Webshop). Eine Telefonnummer des Käufers wird nicht erhoben und nicht gespeichert. Die Löschfristen für Bestelldaten nennt Nummer 9 des AVV Webshop.</p>
<p>(7) Nicht enthalten sind der Verkauf an Verbraucher außerhalb von Deutschland und Österreich, Rechnungen (Nr. 5 Abs. 7), die Verwaltung mehrerer Lager, Rabattcodes, Marktplatzfunktionen, die Anbindung an Warenwirtschaft oder Versanddienstleister, Mehrsprachigkeit und die Zahlung per PayPal (Nr. 4 Abs. 5). Zusätzliche Wünsche, etwa ein Sonderdesign, ein zweiter Webshop oder eine größere Zahl von Artikeln als vorgesehen, gehören nicht zur Einrichtung nach Nr. 9. WEBklar führt sie nach Absprache nach Stunden aus (Nr. 9 Abs. 5).</p>
<h2>3. Ausgeschlossene Waren und Leistungen</h2>
<p>(1) Über den Webshop dürfen nicht verkauft werden:</p>
<ul>
<li>alkoholische Getränke,</li>
<li>Tabakwaren, E-Zigaretten und Zubehör,</li>
<li>Arzneimittel und Medizinprodukte,</li>
<li>Waffen, Munition, Feuerwerk und Sprengstoffe,</li>
<li>Waren mit erotischem oder pornografischem Inhalt,</li>
<li>lebende Tiere,</li>
<li>digitale Inhalte und Downloads,</li>
<li>Gutscheine, Geschenkkarten und Guthaben,</li>
<li>Abonnements und andere wiederkehrende Zahlungen,</li>
<li>Waren und Leistungen, die nach der Liste der verbotenen und eingeschränkten Geschäfte von Stripe nicht erlaubt sind, und</li>
<li>Waren, deren Verkauf oder Versand nach deutschem, österreichischem oder europäischem Recht verboten ist.</li>
</ul>
<p>(2) Der Händler liefert nur nach Deutschland und Österreich. Eine Lieferung in andere Länder bietet der Webshop nicht an.</p>
<p>(3) Der Händler bestätigt bei der Anfrage und bei der Bestellung, dass er keine dieser Waren und Leistungen verkauft. WEBklar darf eine Anfrage ablehnen, wenn der Händler Waren nach Absatz 1 verkaufen will oder die Angaben dazu nicht ausreichen. Verkauft der Händler später Waren nach Absatz 1, darf WEBklar den Webshop nach einem Hinweis in Textform abschalten; bei einem Verstoß, der Rechte Dritter oder Käufer gefährdet, darf WEBklar ihn auch ohne vorherigen Hinweis abschalten und informiert den Händler unverzüglich. Für das Abschalten gilt Nr. 10 Abs. 2 und 3. Eine Erstattung des Einrichtungspreises folgt daraus nicht, wenn der Händler den Verstoß zu vertreten hat.</p>
<h2>4. Zahlungen über Stripe</h2>
<p>(1) Zahlungen im Webshop laufen über Stripe Connect. Der Händler verbindet dazu sein eigenes Stripe-Konto mit dem Webshop. Den Vertrag über dieses Konto schließt der Händler unmittelbar mit Stripe; WEBklar ist daran nicht beteiligt. Für das Konto, die Prüfung des Händlers durch Stripe (zum Beispiel zur Identität) und die Auszahlungen gelten die Bedingungen von Stripe.</p>
<p>(2) Das Geld der Käufer fließt direkt an den Händler. WEBklar erhält und verwahrt kein Geld der Käufer, leitet keines weiter und beteiligt sich nicht am Umsatz des Händlers. WEBklar ist kein Zahlungsdienstleister. Die Gebühren von Stripe trägt der Händler; sie fallen nur zwischen dem Händler und Stripe an.</p>
<p>(3) Der Händler prüft selbst, ob sein Stripe-Konto Zahlungen annehmen und Auszahlungen leisten kann. WEBklar zeigt dem Händler im Kundenportal den Stand, den Stripe meldet, hat aber keinen Einfluss darauf, ob und wann Stripe ein Konto freischaltet, einschränkt oder sperrt, und haftet dafür nicht. Rückbuchungen, Streitfälle mit Käufern und Zahlungsausfälle regeln der Händler und Stripe.</p>
<p>(4) Der Händler erstattet Zahlungen im Kundenportal oder im Stripe-Dashboard. WEBklar trägt Erstattungen aus dem Stripe-Dashboard in die Bestellung nach. Eine Erstattung führt der Händler rechtzeitig aus (Nr. 5 Abs. 6).</p>
<p>(5) Der Webshop bietet die Zahlungsarten an, die Stripe im Checkout für das Konto des Händlers bereitstellt und der Händler in seinen Einstellungen wählt, zum Beispiel Karte und SEPA-Lastschrift. PayPal ist über Stripe für diese Art der Anbindung nicht verfügbar und deshalb nicht Teil des Webshops.</p>
<p>(6) Die Zahlungsseite selbst (Stripe Checkout) betreibt Stripe. Kartendaten und Kontodaten gibt der Käufer nur dort ein; WEBklar erhält sie nicht.</p>
<h2>5. Pflichten des Händlers</h2>
<p>(1) Der Händler hat einen laufenden Vertrag mit WEBklar über den Betrieb seiner Website (Hosting-Abo, Wartungs-Abo oder ein anderer Vertrag über den Betrieb der Website), ist Unternehmer und verkauft in eigenem Namen. Er stellt sein eigenes Stripe-Konto bereit und schließt die Anmeldung bei Stripe selbst ab.</p>
<p>(2) Er verantwortet seine Rechtstexte: Impressum, Datenschutzerklärung, Allgemeine Geschäftsbedingungen, Widerrufsbelehrung, Muster-Widerrufsformular sowie die Angaben zu Versand und Zahlung. Die Mustertexte nach Nr. 6 prüft er, passt sie an seinen Betrieb an und bestätigt sie im Kundenportal, bevor er den Webshop öffnet. Er trägt vor der Bestellung die Adressen von Impressum und Datenschutzerklärung seiner Website ein.</p>
<p>(3) Er verantwortet seine Artikel, Preise und Produktangaben. Dazu gehören richtige Preise einschließlich Steuerausweis (Umsatzsteuer oder Hinweis auf § 19 UStG), Grundpreise, Versandkosten und Lieferzeiten, die Pflichtangaben für Lebensmittel (zum Beispiel Zutaten, Allergene, Füllmenge, verantwortlicher Lebensmittelunternehmer), die Angaben zur Produktsicherheit (zum Beispiel Hersteller, Warn- und Sicherheitshinweise), Widerrufsausschlüsse bei einzelnen Artikeln und die Rechte an seinen Texten und Bildern.</p>
<p>(4) Er liefert die Waren, wie bestellt und beschrieben, und trägt alle Pflichten eines Verkäufers gegenüber seinen Käufern, auch Gewährleistung, Kundenservice und Beschwerden.</p>
<p>(5) Er pflegt Bestand, Bestellstatus und Versandstatus im Kundenportal. Die Folgen falscher Angaben oder Statusmeldungen, die der Händler setzt, trägt er.</p>
<p>(6) Er bearbeitet Widerrufe und Erstattungen selbst, fristgerecht und nach dem Gesetz. Er entfernt den Link „Vertrag widerrufen“ nicht und verändert die Widerrufsfunktion nicht. Er prüft das Kundenportal regelmäßig auf neue Bestellungen und Widerrufe und lässt sich über die Benachrichtigungen informieren.</p>
<p>(7) Rechnungen und Belege an die Käufer erstellt der Händler selbst; WEBklar stellt sie nicht aus. Die Quittung von Stripe und die Bestellbestätigung des Webshops ersetzen keine Rechnung nach dem Umsatzsteuergesetz, soweit der Händler eine ausstellen muss.</p>
<p>(8) Er erfüllt seine Aufbewahrungspflichten selbst, etwa nach der Abgabenordnung und dem Handelsgesetzbuch. WEBklar ist kein Archiv für die Buchführung und kein Buchführungssystem (das Archiv der Rechtstexte nach Nr. 6 Abs. 5 dient nur dem Nachweis der Texte): Personenbezogene Käuferdaten werden nach Nummer 9 des AVV Webshop anonymisiert und nach dem Ende des Webshops gelöscht. Der Händler exportiert seine Bestellungen deshalb rechtzeitig als CSV-Datei und bewahrt sie mit den Belegen von Stripe auf.</p>
<p>(9) Er hält die Vorgaben nach Nr. 3 ein und verändert den Einbau-Code und die gesetzlich vorgeschriebenen Hinweise des Widgets nicht.</p>
<p>(10) Liegt die Datenschutzerklärung in der von WEBklar betreuten Website, fügt WEBklar beim Einbau dort den Datenschutz-Absatz für den Webshop ein; das gehört zur Einrichtung. Liegt sie woanders, erhält der Händler den Absatz als Textbaustein und fügt ihn selbst ein. Für den übrigen Inhalt seiner Datenschutzerklärung bleibt er verantwortlich (§ 6 Abs. 3 der Abo-Bedingungen). Fällt WEBklar beim Einfügen ein offensichtlicher Widerspruch auf, weist WEBklar den Händler darauf hin; eine Pflicht zur Prüfung der Datenschutzerklärung entsteht daraus nicht.</p>
<p>(11) Der Händler informiert seine Käufer nach Art. 13 DSGVO. Die Rollen im Datenschutz regelt der AVV Webshop: Der Händler ist Verantwortlicher, WEBklar Auftragsverarbeiter, Stripe Vertragspartner des Händlers.</p>
<h2>6. Muster-Rechtstexte, keine Rechtsberatung</h2>
<p>(1) WEBklar stellt dem Händler Mustertexte bereit: eine Muster-Widerrufsbelehrung, ein Muster-Widerrufsformular, Allgemeine Geschäftsbedingungen und den Text zu Versand und Zahlung. Das Widget zeigt außerdem die Hinweise, die für die Kasse und die Bestellbestätigung vorgesehen sind, darunter den Hinweis auf die gesetzliche Gewährleistung.</p>
<p>(2) Die Mustertexte sind <strong>Muster ohne Gewähr</strong>. Sie passen zur Standardkonfiguration des Webshops und können auf den Betrieb des Händlers nicht zugeschnitten sein. WEBklar sorgt dafür, dass sie die Technik des Webshops richtig beschreiben, und passt sie an, wenn sich die Technik ändert. Eine rechtliche Beratung im Einzelfall schuldet WEBklar nicht (kein Rechtsrat im Sinne des Rechtsdienstleistungsgesetzes).</p>
<p>(3) WEBklar empfiehlt, die Rechtstexte vor der Öffnung des Webshops durch eine Rechtsanwältin oder einen Rechtsanwalt prüfen zu lassen, besonders bei besonderen Waren (zum Beispiel Lebensmitteln, Kosmetik, Textilien mit Pflegekennzeichnung, Elektrogeräten).</p>
<p>(4) Der Händler bleibt für seine Rechtstexte verantwortlich, auch wenn er Mustertexte unverändert übernimmt (Nr. 5 Abs. 2).</p>
<p>(5) Bestätigt der Händler seine Rechtstexte im Kundenportal, legt WEBklar jede bestätigte Fassung mit Prüfsumme, Zeitpunkt und der bestätigenden Person im Archiv ab. Die Bestellbestätigung zeigt dem Käufer die archivierte Fassung, die zum Zeitpunkt seiner Bestellung galt. WEBklar löscht das Archiv mit den übrigen Daten des Webshops (Nr. 10 Abs. 3; Nummer 8 Abs. 5 des AVV Webshop). Der Händler bewahrt die Fassungen, die er für eigene Aufbewahrungspflichten braucht, selbst auf (Nr. 5 Abs. 8).</p>
<h2>7. Verfügbarkeit und Störungen</h2>
<p>(1) Das Verfügbarkeitsziel nach § 5 der Abo-Bedingungen gilt für die Website, nicht für den Webshop. WEBklar sagt für den Webshop keine bestimmte Verfügbarkeit zu, die über den Betrieb der Website im Abo hinausgeht. Störungen des Webshops beheben wir in angemessener Frist (§ 12 der Abo-Bedingungen).</p>
<p>(2) Angekündigte Wartungsfenster (§ 5 der Abo-Bedingungen) können den Webshop kurzzeitig unterbrechen. Während einer Unterbrechung sind Bestellungen im Widget nicht möglich; das Widget zeigt dann einen Ausweichtext.</p>
<p>(3) Zahlungen übernimmt der Webshop über Benachrichtigungen von Stripe und einen regelmäßigen Abgleich in die Bestellung. Fällt die Verbindung zu Stripe oder zum Server aus, kann sich die Bestätigung einer Bestellung verzögern. Ausfälle von Stripe, von Zahlungsarten und von Banken liegen nicht im Einflussbereich von WEBklar.</p>
<p>(4) Alle Zeiten gelten in der Zeitzone Europe/Berlin.</p>
<p>(5) WEBklar übernimmt keine Gewähr dafür, dass E-Mails zugestellt und gelesen werden. Das hängt vom Postfach des Empfängers und von den Mailgrenzen seines Anbieters ab.</p>
<p>(6) Die gesetzlichen Rechte des Händlers sowie §§ 12 und 13 der Abo-Bedingungen bleiben unberührt.</p>
<h2>8. Haftung</h2>
<p>(1) Der Webshop ist ein Hilfsmittel für den Verkauf des Händlers. Der Vertrag über die Ware besteht allein zwischen dem Händler und dem Käufer; WEBklar ist daran nicht beteiligt.</p>
<p>(2) Für entgangenen Gewinn und Folgeschäden aus Störungen des Webshops, aus Fehlern bei Bestand oder Bestellstatus und aus nicht zugestellten E-Mails haftet WEBklar nur im Rahmen von § 13 Abs. 1 und 2 der Abo-Bedingungen.</p>
<p>(3) WEBklar haftet nicht für Inhalte des Händlers, insbesondere nicht für seine Artikel, Preise, Produktangaben und Rechtstexte, auch nicht für Mustertexte, die er übernommen hat (Nr. 6), und nicht für die Erfüllung seiner Pflichten gegenüber den Käufern (Nr. 5).</p>
<p>(4) WEBklar haftet nicht für Ausfälle, Sperren oder Entscheidungen von Stripe und der Zahlungsarten sowie für Rückbuchungen und Zahlungsausfälle (Nr. 4 Abs. 3).</p>
<p>(5) Die Haftung nach § 13 Abs. 1 der Abo-Bedingungen (unter anderem bei Vorsatz, grober Fahrlässigkeit, Verletzung von Leben, Körper oder Gesundheit und nach dem Produkthaftungsgesetz) bleibt unberührt. Der Haftungsrahmen des Händlers gegenüber seinen Käufern bleibt unberührt.</p>
<h2>9. Preis, Abrechnung und Widerruf</h2>
<p>(1) Die Einrichtung kostet 299 € einmalig je Website-Projekt, ohne Umsatzsteuer gemäß § 19 UStG. Für den Betrieb des Webshops berechnet WEBklar nichts weiter; es gibt keine laufende Gebühr und keine Beteiligung am Umsatz. Der Webshop läuft, solange WEBklar die Website betreibt (Nr. 10).</p>
<p>(2) Die Einrichtung dauert nach der Erfahrung von WEBklar meist 2 bis 3 Wochen ab der Bestellung. Sie hängt davon ab, wie schnell der Händler Stripe-Konto, Artikel und Rechtstexte bereitstellt und wie umfangreich der Einbau ist. Die Dauer ist eine Einschätzung und keine verbindliche Zusage eines Termins.</p>
<p>(3) Der Händler bezahlt bei der Bestellung im Kundenportal über Stripe und erhält eine Rechnung.</p>
<p>(4) Der Preis steht neben den Leistungen eines Abos: Die Einrichtung wird weder auf ein Stundenkontingent angerechnet noch damit bezahlt.</p>
<p>(5) Mehraufwand, der nicht zur Einrichtung gehört (Nr. 2 Abs. 7), führt WEBklar nur nach Absprache vor der Ausführung und nach Freigabe des Händlers in Textform aus. WEBklar nennt vorher den geschätzten Aufwand und berechnet 100 € je Stunde in Schritten von 15 Minuten, ohne Umsatzsteuer gemäß § 19 UStG (§ 4 der Abo-Bedingungen). Der Mehraufwand wird gesondert berechnet und nicht auf ein Stundenkontingent angerechnet.</p>
<p>(6) Verbraucher können die Bestellung der Einrichtung nach der Widerrufsbelehrung widerrufen. Belehrung und Muster-Widerrufsformular stehen in der Anlage „Widerrufsbelehrung und Muster-Widerrufsformular (Einrichtung von Funktionen)“. Ob das Widerrufsrecht erlischt und ob der Händler einen angemessenen Betrag für die bis dahin erbrachte Leistung schuldet, richtet sich nach der Zustimmung, die er bei der Bestellung gibt, und nach den Hinweisen zum Widerruf in der Anlage. Mit dem Widerruf endet der Webshop.</p>
<h2>10. Laufzeit und Ende</h2>
<p>(1) Der Webshop läuft, solange WEBklar die Website des Händlers betreibt. Endet der Vertrag über den Betrieb der Website, endet auch der Webshop; WEBklar schaltet ihn zum Vertragsende ab und informiert den Händler vorher per E-Mail. Das gilt auch beim Widerruf dieses Vertrags.</p>
<p>(2) Mit dem Abschalten nimmt der Webshop keine neuen Bestellungen mehr an, und das Widget zeigt den Ausweichtext. Bestellungen, die noch nicht bezahlt sind, laufen aus. Die Bestellungen und Widerrufe bleiben im Kundenportal 90 Tage lesbar, und der Händler kann sie als CSV-Datei exportieren. Die Widerrufsfunktion bleibt bis zur Löschung nach Absatz 3 erreichbar, soweit das Widget noch eingebunden ist. Der Händler erfüllt seine Pflichten gegenüber den Käufern weiter (Nr. 5) und informiert sie, wenn er den Webshop nicht mehr anbietet.</p>
<p>(3) WEBklar löscht alle Daten des Webshops, also Artikel, Bilder, Bestellungen, Widerrufe, Einstellungen, Rechtstexte und das Archiv der bestätigten Fassungen, 90 Tage nach dem Abschalten. Eine Ausnahme ist das Einrichtungs-Ticket, in dem WEBklar die Einrichtung als Arbeitsnachweis festhält: Es bleibt mit einer Kurzfassung der Angaben zur Einrichtung bestehen und enthält keine Daten von Käufern (Nummer 9 Abs. 6 des AVV Webshop).</p>
<p>(4) Das Stripe-Konto bleibt beim Händler. Das Ende des Webshops berührt weder das Konto noch die Auszahlungen noch den Vertrag mit Stripe. Der Händler kann die Verbindung zwischen dem Konto und dem Webshop im Stripe-Dashboard beenden.</p>
<p>(5) Beginnt innerhalb der 90 Tage wieder ein Vertrag über den Betrieb der Website, schaltet WEBklar den Webshop auf Wunsch des Händlers in Textform mit den vorhandenen Daten und Einstellungen ohne weitere Kosten wieder ein. Nach der Löschung ist eine neue Anfrage und eine neue Einrichtung nötig (neuer Preis nach Nr. 9).</p>
<p>(6) Endet der Vertrag über den Betrieb der Website, bevor WEBklar den Webshop freigeschaltet hat, erstattet WEBklar den Einrichtungspreis vollständig.</p>
<p>(7) Auf Wunsch des Händlers in Textform schaltet WEBklar den Webshop jederzeit ab. Eine Erstattung des Einrichtungspreises folgt daraus nicht; für den Widerruf gilt Nr. 9 Abs. 6.</p>
<h2>11. Änderungen und Einstellung</h2>
<p>(1) WEBklar darf die Technik des Webshops verbessern, solange der Leistungsumfang nicht schlechter wird (§ 3 Abs. 7 der Abo-Bedingungen). Ändert sich dabei, welche Daten erhoben werden, gilt Nummer 12 des AVV Webshop.</p>
<p>(2) WEBklar darf den Webshop für eine Website mit einer Frist von vier Wochen beenden oder für alle Händler mit einer Frist von drei Monaten einstellen, wenn</p>
<ul>
<li>ein Gesetz, eine Gerichtsentscheidung oder eine Vorgabe einer Aufsichtsbehörde den Betrieb in der bisherigen Form unzulässig macht oder nur mit wesentlich anderer Technik erlaubt und der Händler einer dafür nötigen neuen Fassung nicht zustimmt (AVV Webshop Nummer 12),</li>
<li>Stripe die Anbindung nach Nr. 4 beendet oder wesentlich ändert, so dass der Webshop in der bisherigen Form nicht weiterbetrieben werden kann, oder</li>
<li>eine Sicherheitslücke sich nicht anders beheben lässt.</li>
</ul>
<p>Die Mitteilung erfolgt in Textform und nennt den Grund, die Merkmale der Änderung, den Zeitpunkt und bei Verbrauchern das Recht, den Vertrag nach § 327r Abs. 3 BGB zu beenden, mit seiner Frist.</p>
<p>(3) Ist der Weiterbetrieb aus einem Grund nach Absatz 2 schon vor Ablauf der Frist nicht zulässig, darf WEBklar den Webshop ohne Vorankündigung aussetzen, solange das nötig ist. WEBklar informiert den Händler darüber unverzüglich in Textform mit dem Grund und nimmt den Betrieb wieder auf, sobald das zulässig und technisch möglich ist.</p>
<p>(4) Eine Einstellung ohne einen Grund nach Absatz 2 ist ausgeschlossen. Gesetzliche Rechte von Verbrauchern bei Änderungen digitaler Produkte (§ 327r BGB) bleiben unberührt.</p>
<h2>12. Hinweise</h2>
<p>(1) <strong>Keine Rechtsberatung.</strong> Der Webshop und die Mustertexte sind technische Hilfsmittel, keine Rechtsberatung (Nr. 6). Die rechtliche Einordnung seiner Waren, seiner Preisangaben und seines Angebots trägt der Händler.</p>
<p>(2) <strong>Barrierefreiheit.</strong> Das Widget ist für die Bedienung mit Tastatur und Hilfstechniken gestaltet (Beschriftungen, Fokusführung, Fehlermeldungen als Live-Region). Eine Konformität nach WCAG 2.1 AA oder EN 301 549 sagt WEBklar nicht zu. Ob der Händler als Anbieter von Dienstleistungen im elektronischen Geschäftsverkehr unter das BFSG fällt (Ausnahme für Kleinstunternehmen, § 3 Abs. 3 BFSG), entscheidet und prüft der Händler.</p>
</main>
</body>
</html>
@@ -0,0 +1 @@
081f98f8a0be20d003863c25417e9896ace5e6fbbacaf6ec3c481f32edee1018
+167
View File
@@ -0,0 +1,167 @@
/* Webshop im Kundenbereich: die Anfrage („auf Anfrage“, Zustand off). Das Formular (renderAnfrageFormular), die Karte einer offenen oder
abgelehnten Anfrage (anfrageKarte) und die Zusammenfassung der Angaben (anfrageZeilen, auch fuer die Bestellseite).
Schnittstelle (server/routes/shop.js): POST /api/shop/anfrage { projectId, anfrage: { warenart, beschreibung, artikelzahl, versand, abholung,
unternehmer, keineAusgeschlossenen, nachricht } } und POST /api/shop/anfrage/zurueckziehen { projectId }. Geprueft wird nur auf dem Server
(Feldfehler kommen als err.fehler zurueck und stehen am Feld). Text kommt nur ueber textContent in die Seite (h). */
import { allgemeinerFehler, datumText, feld, h, hakenFeld, request, setzeFehler, zeigeFehler } from './shop-dom.js'
export const WARENARTEN = [['waren', 'Waren'], ['lebensmittel', 'Lebensmittel'], ['kleidung', 'Kleidung'], ['kunst', 'Kunst'], ['sonstiges', 'Sonstiges']]
export const ARTIKELZAHLEN = [['bis20', 'bis 20'], ['bis100', 'bis 100'], ['mehr', 'mehr als 100']]
export const AUSGESCHLOSSEN = 'Alkohol, Tabak/E-Zigaretten, Arzneimittel, Waffen/Feuerwerk, Erotik, lebende Tiere, digitale Inhalte, Gutscheine'
const labelVon = (liste, key) => liste.find(([k]) => k === key)?.[1] || ''
const text = (wert) => (typeof wert === 'string' ? wert : '')
/** Angaben der Anfrage als Zeilen [{ name, wert }] (Gruppe der Bestellseite und Zusammenfassung der Karte). */
export function anfrageZeilen(anfrage) {
const a = anfrage && typeof anfrage === 'object' ? anfrage : {}
const wege = [a.versand === true ? 'Versand' : '', a.abholung === true ? 'Abholung' : ''].filter(Boolean).join(' und ')
return [
['Was du verkaufst', labelVon(WARENARTEN, a.warenart)],
['Beschreibung', text(a.beschreibung)],
['Artikel', labelVon(ARTIKELZAHLEN, a.artikelzahl)],
['Lieferung', wege],
['Nachricht', text(a.nachricht)],
].filter(([, wert]) => wert !== '').map(([name, wert]) => ({ name, wert }))
}
/** Die Angaben als Definitionsliste (nur Text). */
export const anfrageListe = (anfrage) => h('dl', { class: 'sh-fakten' }, anfrageZeilen(anfrage).map(({ name, wert }) => h('div', null, h('dt', null, name), h('dd', null, wert))))
/**
* Formular „Webshop anfragen“ in root. optionen: projectId, vorgabe (frueherer Stand der Anfrage zum Vorausfuellen), preis ('299 €'),
* ustHinweis (Satz zur Umsatzsteuer), nachSenden(anfrage) (die Anfrage ist gespeichert: die Ansicht zeichnet sich neu).
* Der Server prueft alles; ein 409 (z. B. laeuft schon eine Anfrage) zeigt seinen Text und laesst die Ansicht neu laden.
*/
export function renderAnfrageFormular(root, { projectId, vorgabe = null, preis = '299 €', ustHinweis = '', nachSenden } = {}) {
const v = vorgabe && typeof vorgabe === 'object' ? vorgabe : {}
const auswahl = (liste, aktuell) => {
const optionen = [h('option', { value: '' }, 'Bitte wählen …'), ...liste.map(([wert, name]) => h('option', { value: wert }, name))]
const select = h('select', null, optionen)
select.value = aktuell || ''
return select
}
const warenart = auswahl(WARENARTEN, v.warenart)
const artikelzahl = auswahl(ARTIKELZAHLEN, v.artikelzahl)
const beschreibung = h('textarea', { rows: 4, maxlength: 1500 })
beschreibung.value = text(v.beschreibung)
const versand = h('input', { type: 'checkbox' })
versand.checked = v.versand === true
const abholung = h('input', { type: 'checkbox' })
abholung.checked = v.abholung === true
const unternehmer = h('input', { type: 'checkbox' })
const ausgeschlossen = h('input', { type: 'checkbox' })
const nachricht = h('textarea', { rows: 3, maxlength: 1000 })
nachricht.value = text(v.nachricht)
const absenden = h('button', { type: 'submit', class: 'kb-btn-primary', 'data-aktion': 'anfrage-senden' }, 'Anfrage senden')
const form = h('form', { class: 'sh-form', novalidate: true, 'aria-label': 'Webshop anfragen' },
feld('warenart', 'Was möchtest du verkaufen?', warenart),
feld('beschreibung', 'Beschreibe kurz, was du verkaufst', beschreibung, 'Zum Beispiel: handgemachte Kerzen, Bio-Honig oder Fanartikel eines Vereins.'),
feld('artikelzahl', 'Wie viele Artikel möchtest du etwa anbieten?', artikelzahl),
h('fieldset', { class: 'sh-gruppe' },
h('legend', null, 'Wie bekommen deine Kunden die Ware?'),
hakenFeld('versand', versand, 'Versand'),
hakenFeld('abholung', abholung, 'Abholung')),
hakenFeld('unternehmer', unternehmer, 'Ich verkaufe als Unternehmer (nicht privat).'),
hakenFeld('keineAusgeschlossenen', ausgeschlossen, `Ich verkaufe keine ausgeschlossenen Waren (${AUSGESCHLOSSEN}).`),
feld('nachricht', 'Noch etwas, das wir wissen sollten? (freiwillig)', nachricht),
allgemeinerFehler(),
h('div', { class: 'sh-aktionen' }, absenden))
let laeuft = false
form.addEventListener('submit', async (ev) => {
ev.preventDefault()
if (laeuft) return
laeuft = true
absenden.disabled = true
setzeFehler(form, {})
try {
const antwort = await request('/api/shop/anfrage', {
method: 'POST',
body: {
projectId,
anfrage: {
warenart: warenart.value,
beschreibung: beschreibung.value.trim(),
artikelzahl: artikelzahl.value,
versand: versand.checked,
abholung: abholung.checked,
unternehmer: unternehmer.checked,
keineAusgeschlossenen: ausgeschlossen.checked,
nachricht: nachricht.value.trim(),
},
},
})
if (typeof nachSenden === 'function') await nachSenden(antwort?.anfrage ?? null)
return
} catch (err) {
zeigeFehler(form, err)
if (err?.status === 409 && typeof nachSenden === 'function') {
// der Stand hat sich geaendert (z. B. laeuft schon eine Anfrage): Ansicht neu laden, die Meldung des Servers bleibt am Formular stehen
try { await nachSenden(null) } catch { /* der Fehler steht schon am Formular */ }
return
}
}
laeuft = false
absenden.disabled = false
})
const erklaerung = h('div', { class: 'sh-erklaerung' },
h('h3', null, 'Webshop anfragen'),
h('p', null, 'Mit dem Webshop verkaufst du direkt auf deiner Website: deine Kunden legen Artikel in den Warenkorb und bezahlen online.'),
h('ul', null,
h('li', null, `${preis} einmalig für den Einbau in deine Website, keine laufende Gebühr von WEBklar.`),
h('li', null, 'Die Zahlungen gehen direkt auf dein eigenes Stripe-Konto. Die Gebühren von Stripe zahlst du direkt an Stripe.'),
h('li', null, 'Deine Kunden zahlen mit Karte, Apple Pay, Google Pay, Klarna oder SEPA-Lastschrift.'),
h('li', null, 'PayPal ist über Stripe leider nicht möglich.'),
h('li', null, 'Wir sehen uns deine Anfrage kurz an. Erst nach unserer Zusage kannst du bestellen.')),
ustHinweis ? h('p', { class: 'sh-hinweis' }, ustHinweis) : null)
const karte = h('section', { class: 'kb-panel sh-anfrage' }, erklaerung, form)
root.replaceChildren(karte)
return karte
}
/**
* Karte einer Anfrage im Stand offen (wird geprueft, mit „Anfrage zurückziehen“) oder abgelehnt (Begruendung, „Neue Anfrage stellen“).
* ansicht = status.anfrage (anfrageAnsicht des Servers), optionen: projectId, neu() (Formular zeigen), neuLaden() (Stand holen),
* kundenansicht (sperrt das Zurueckziehen).
*/
export function anfrageKarte(ansicht, { projectId, neu, neuLaden } = {}) {
const offen = ansicht?.status === 'offen'
const meldung = h('p', { class: 'sh-fehler', role: 'alert', 'data-meldung': 'anfrage' })
const datum = datumText(ansicht?.at)
let aktion
if (offen) {
aktion = h('button', { type: 'button', class: 'kb-btn-outline', 'data-aktion': 'zurueckziehen' }, 'Anfrage zurückziehen')
aktion.addEventListener('click', async () => {
if (aktion.disabled) return
aktion.disabled = true
meldung.textContent = ''
try {
await request('/api/shop/anfrage/zurueckziehen', { method: 'POST', body: { projectId } })
if (typeof neuLaden === 'function') await neuLaden()
return
} catch (err) {
meldung.textContent = err.message
// 409: die Anfrage ist inzwischen entschieden; der neue Stand erscheint
if (err?.status === 409 && typeof neuLaden === 'function') { try { await neuLaden() } catch { /* Meldung bleibt */ } return }
}
aktion.disabled = false
})
} else {
aktion = h('button', { type: 'button', class: 'kb-btn-primary', 'data-aktion': 'neue-anfrage', onclick: () => { if (typeof neu === 'function') neu() } }, 'Neue Anfrage stellen')
}
return h('section', { class: 'kb-panel sh-info sh-anfrage-karte', 'data-anfrage': offen ? 'offen' : 'abgelehnt' },
h('span', { class: `sh-pille ${offen ? 'sh-pille-wait' : 'sh-pille-bad'}` }, offen ? 'In Prüfung' : 'Abgelehnt'),
h('h3', null, offen ? 'Deine Anfrage wird geprüft' : 'Deine Anfrage wurde leider abgelehnt'),
offen
? h('p', null, `${datum ? `Du hast deine Anfrage am ${datum} Uhr gesendet. ` : ''}Wir melden uns, sobald wir sie angesehen haben. Bis dahin kannst du sie zurückziehen und eine neue stellen.`)
: h('p', null, 'Unsere Begründung:'),
!offen ? h('blockquote', { class: 'sh-begruendung' }, text(ansicht?.begruendung) || 'Dazu liegt keine Begründung vor.') : null,
ansicht?.anfrage ? h('div', { class: 'sh-zusammenfassung' }, h('h4', null, 'Deine Angaben'), anfrageListe(ansicht.anfrage)) : null,
meldung,
h('div', { class: 'sh-aktionen' }, aktion, h('button', { type: 'button', class: 'kb-btn-outline', 'data-view': 'chat' }, 'Frage dazu? Chat öffnen')))
}
+178
View File
@@ -0,0 +1,178 @@
/* Webshop im Kundenbereich: Helfer ohne Zustand (Elemente bauen, Schnittstelle, Formularfelder, Hinweiskarten, Euro und Datum).
Eigene Kopie der Helfer aus termine-dom.js (die Webshop-Dateien importieren nichts aus termine*.js); Klassen mit sh-.
Alle Module der Ansicht (shop.js, shop-anfrage.js, shop-tab-*.js) bauen die Seite nur hiermit: Kundentext kommt ausschliesslich
ueber textContent in die Seite (h), nie ueber innerHTML. */
/** Element bauen: Texte nur als Textknoten, Attribute mit on… als Ereignis, true setzt ein Attribut ohne Wert, false/null/undefined lassen es weg. */
export function h(tag, attrs, ...kinder) {
const el = document.createElement(tag)
for (const [name, wert] of Object.entries(attrs || {})) {
if (wert === undefined || wert === null || wert === false) continue
if (name === 'class') el.className = wert
else if (name.startsWith('on')) el.addEventListener(name.slice(2), wert)
else el.setAttribute(name, wert === true ? '' : wert)
}
for (const kind of kinder.flat()) if (kind !== undefined && kind !== null && kind !== false) el.append(kind)
return el
}
/** Schnittstelle: JSON hin und her; Fehler als Error mit status, fehler (Feldfehler), code und body (ganze Antwort), Netzwerkfehler mit status 0. */
export async function request(path, { method = 'GET', body } = {}) {
let response
try {
response = await fetch(path, {
method,
credentials: 'same-origin',
headers: body ? { 'Content-Type': 'application/json' } : {},
body: body ? JSON.stringify(body) : undefined,
})
} catch {
throw Object.assign(new Error('Keine Verbindung. Bitte versuch es gleich noch einmal.'), { status: 0, fehler: {} })
}
const data = await response.json().catch(() => ({}))
if (!response.ok) {
const err = new Error(data.error || (response.status === 502 ? 'Server nicht erreichbar. Bitte kurz warten und noch einmal versuchen.' : `Fehler ${response.status}`))
err.status = response.status
err.fehler = data.fehler || {}
err.code = data.code
err.body = data !== null && typeof data === 'object' ? data : {}
throw err
}
return data
}
let idZaehler = 0
const neueId = () => `sh-f${++idZaehler}`
/** Formularfeld mit Beschriftung, Hinweis und Platz fuer den Fehler (data-feld = Feldname der Schnittstelle). */
export function feld(name, label, steuer, hinweis) {
const id = steuer.id || (steuer.id = neueId())
steuer.dataset.feld = name
return h('div', { class: 'sh-feld' },
h('label', { for: id }, label),
steuer,
hinweis ? h('p', { class: 'sh-hinweis' }, hinweis) : null,
h('p', { class: 'sh-fehler', 'data-fehler': name, role: 'alert' }))
}
/** Ankreuzfeld (input type=checkbox) mit Beschriftung, Hinweis und Platz fuer den Fehler. */
export function hakenFeld(name, steuer, text, hinweis) {
steuer.dataset.feld = name
return h('div', { class: 'sh-feld sh-feld-haken' },
h('label', { class: 'sh-haken' }, steuer, h('span', null, text, hinweis ? h('small', { class: 'sh-hinweis' }, hinweis) : null)),
h('p', { class: 'sh-fehler', 'data-fehler': name, role: 'alert' }))
}
/** Platz fuer Fehler, die zu keinem Feld gehoeren (Feldname „_“). */
export const allgemeinerFehler = () => h('p', { class: 'sh-fehler', 'data-fehler': '_', role: 'alert' })
export function setzeFehler(form, fehler) {
for (const el of form.querySelectorAll('[data-fehler]')) el.textContent = ''
for (const el of form.querySelectorAll('[aria-invalid]')) el.removeAttribute('aria-invalid')
const rest = []
for (const [name, text] of Object.entries(fehler || {})) {
// „_“ und Namen ohne Platz im Formular werden allgemein gezeigt
const ziel = name === '_' ? null : form.querySelector(`[data-fehler="${CSS.escape(name)}"]`)
if (ziel) {
ziel.textContent = text
form.querySelector(`[data-feld="${CSS.escape(name)}"]`)?.setAttribute('aria-invalid', 'true')
} else {
rest.push(text)
}
}
const allgemein = form.querySelector('[data-fehler="_"]')
if (allgemein) allgemein.textContent = rest.join(' ')
}
/** Fehler der Schnittstelle am Formular zeigen (Feldfehler am Feld, sonst allgemein); der Fokus geht auf das erste fehlerhafte Feld. */
export function zeigeFehler(form, err) {
const fehler = err.fehler && Object.keys(err.fehler).length ? err.fehler : { _: err.message }
setzeFehler(form, fehler)
form.querySelector('[aria-invalid]')?.focus()
}
/** Rueckfrage in der Seite (kein confirm()): Formular mit Titel; der Fokus gehoert dem Aufrufer. */
export function frage(titel, ...inhalt) {
return h('form', { class: 'sh-frage-box', novalidate: true, tabindex: '-1', 'aria-label': titel, onsubmit: (ev) => ev.preventDefault() }, h('strong', null, titel), ...inhalt)
}
/** Abschnitt eines Reiters: Karte mit Ueberschrift und einem erklaerenden Satz (hinweis darf fehlen). */
export const abschnitt = (titel, hinweis, ...inhalt) => h('section', { class: 'kb-panel sh-abschnitt' },
h('h3', null, titel),
hinweis ? h('p', { class: 'sh-hinweis sh-abschnitt-text' }, hinweis) : null,
...inhalt)
export const infoKarte = (titel, text, { chat = false } = {}) => h('div', { class: 'kb-panel sh-info' },
h('h3', null, titel),
text ? h('p', null, text) : null,
chat ? h('button', { type: 'button', class: 'kb-btn-outline', 'data-view': 'chat' }, 'Chat öffnen') : null)
export const fehlerKarte = (text, nochmal) => h('div', { class: 'kb-panel sh-info', role: 'alert' },
h('p', null, text),
nochmal ? h('button', { type: 'button', class: 'kb-btn-outline', onclick: nochmal }, 'Noch einmal versuchen') : null)
/* ------------------------------------------------------------------ Euro und Datum --- */
/**
* Euro aus einem Feld in Cent (nur Anzeige und Vorpruefung, den Betrag prueft der Server). Deutsche Schreibweise: ',' trennt die Cent,
* '.' die Tausender ('1.234,50'); '45.50' und '12.5' gelten als Euro und Cent. null bei allem anderen (leer, Buchstaben, mehr als zwei
* Nachkommastellen).
*/
export function euroZuCent(text) {
if (typeof text !== 'string') return null
const s = text.replace(/€/g, '').trim()
if (!/^[\d.,]+$/.test(s)) return null
let ganz = s
let rest = ''
const komma = s.indexOf(',')
if (komma >= 0) {
ganz = s.slice(0, komma)
rest = s.slice(komma + 1)
if (/[.,]/.test(rest)) return null
if (ganz.includes('.') && !/^\d{1,3}(\.\d{3})+$/.test(ganz)) return null
ganz = ganz.replaceAll('.', '')
} else if (/^\d{1,3}(\.\d{3})+$/.test(s)) {
ganz = s.replaceAll('.', '')
} else if (s.includes('.')) {
const teile = s.split('.')
if (teile.length !== 2) return null
;[ganz, rest] = teile
}
if (!/^\d{1,9}$/.test(ganz) || !/^\d{0,2}$/.test(rest)) return null
return Number(ganz) * 100 + Number(rest.padEnd(2, '0'))
}
/** 1250 ⇒ '12,50' (Feldwert); unbrauchbar ⇒ ''. */
export const centZuEuro = (cent) => (Number.isInteger(cent) && cent >= 0 ? `${Math.floor(cent / 100)},${String(cent % 100).padStart(2, '0')}` : '')
/** 1250 ⇒ '12,50 €' (mit Tausenderpunkt, geschuetztes Leerzeichen vor €); unbrauchbar ⇒ ''. */
export function euroText(cent) {
if (!Number.isInteger(cent) || cent < 0) return ''
return `${String(Math.floor(cent / 100)).replace(/\B(?=(\d{3})+(?!\d))/g, '.')},${String(cent % 100).padStart(2, '0')} €`
}
/**
* Eingabefeld fuer einen Euro-Betrag (Text, nie type=number: Komma statt Punkt). Der Wert steht als '12,50' im Feld; beim Verlassen wird eine
* lesbare Eingabe ('12,5', '12.50 €') in diese Schreibweise gebracht. steuer.inCent() liefert die Cent (null bei leer oder unlesbar),
* steuer.istLeer() und steuer.istLesbar() erklaeren warum. Gesendet wird, was der Aufrufer daraus macht; der Server prueft den Betrag.
*/
export function euroEingabe({ cent = null, id, placeholder = '0,00', label } = {}) {
const steuer = h('input', { type: 'text', inputmode: 'decimal', autocomplete: 'off', placeholder, id, 'aria-label': label })
steuer.value = centZuEuro(cent)
steuer.istLeer = () => String(steuer.value).trim() === ''
steuer.inCent = () => (steuer.istLeer() ? null : euroZuCent(String(steuer.value)))
steuer.istLesbar = () => steuer.istLeer() || steuer.inCent() !== null
steuer.addEventListener('change', () => {
const c = steuer.inCent()
if (c !== null) steuer.value = centZuEuro(c)
})
return steuer
}
/** Datum und Uhrzeit in Berlin als '05.10.2026, 14:30'; nur das Datum mit { zeit: false }; bei fehlendem oder ungueltigem Wert ''. */
export function datumText(wert, { zeit = true } = {}) {
const tag = new Date(wert)
if (!wert || Number.isNaN(tag.getTime())) return ''
const optionen = { timeZone: 'Europe/Berlin', day: '2-digit', month: '2-digit', year: 'numeric' }
if (zeit) Object.assign(optionen, { hour: '2-digit', minute: '2-digit', hourCycle: 'h23' })
return tag.toLocaleString('de-DE', optionen)
}
+23
View File
@@ -0,0 +1,23 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex">
<title>Shop</title>
<style>
body{margin:0;font-family:system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;background:#f6f7f9;color:#1f2937;line-height:1.5}
.seitenkopf{padding:16px 24px;background:#fff;border-bottom:1px solid #e5e7eb;font-weight:700}
main{max-width:960px;margin:0 auto;padding:24px 16px 48px}
.ausweich{margin:0;padding:16px;color:#4b5563}
</style>
</head>
<body>
<header class="seitenkopf" id="seitenkopf">Shop</header>
<main>
<div data-webklar-shop="__SITEKEY__" data-ansicht="__ANSICHT__" data-api=""><p class="ausweich">Der Shop wird geladen …</p></div>
<noscript><p class="ausweich">Für den Shop wird JavaScript benötigt.</p></noscript>
</main>
<script src="/embed/shop.js" defer></script>
</body>
</html>
+570
View File
@@ -0,0 +1,570 @@
/* Webshop, Reiter „Artikel“ (Plan 2026-10-05-webshop.md, Task G4; Spec 4.3). render(panel, ctx): ctx = { projectId, nurLesen, aktuell(), zeigeMeldung(text, art), request } (shop.js).
Zwei Ansichten im selben Reiter: die Liste (Name, Preis, Bestand, Schalter „aktiv“, Hoch/Runter, Bearbeiten, Loeschen) und der Editor (alle Felder des Artikels,
Varianten, Bilder mit Upload, Produktsicherheit, Lebensmittel). Server: GET/POST /api/shop/produkte, PUT/DELETE /api/shop/produkte/:id, POST /api/shop/produkte/sortierung,
POST /api/shop/bild (rohe Bytes), Vorschau GET /api/shop/bild/:projectId/<id>-s.webp, GET /api/shop/einstellungen (Kleinunternehmer). Der Server prueft alles; der Editor
gibt Feldfehler (auch mit Punkt-Pfad wie 'varianten.werte.2.preisCent') am Feld aus (data-fehler = Pfad). Fremdtext kommt nur ueber textContent in die Seite (h), nie ueber innerHTML. */
import { abschnitt, centZuEuro, euroEingabe, euroText, euroZuCent, feld, fehlerKarte, frage, h, hakenFeld, request, setzeFehler, zeigeFehler } from './shop-dom.js'
const MAX_WERTE = 30
const BILD_MAX_BYTES = 8 * 1024 * 1024
const BILD_TYPEN = ['image/jpeg', 'image/png', 'image/webp']
const EINHEITEN = [['g', 'g'], ['kg', 'kg'], ['ml', 'ml'], ['l', 'l'], ['m', 'm'], ['m2', 'm²']]
const STEUERSAETZE = [19, 7, 0]
const LIEFERARTEN = [['beides', 'Versand und Abholung'], ['abholung', 'Nur Abholung']]
const WIDERRUFE = [
['gesetzlich', 'Gesetzliches Widerrufsrecht (Standard)'],
['verderblich', 'Ausgeschlossen: schnell verderbliche Ware'],
['individuell', 'Ausgeschlossen: nach Kundenwunsch angefertigt'],
['hygiene', 'Ausgeschlossen: versiegelt, aus Hygienegründen'],
]
const BILD_FEHLER = {
413: 'Das Bild ist größer als 8 MB.',
415: 'Dieses Bild kann nicht verwendet werden. Bitte nimm eine JPEG-, PNG- oder WebP-Datei.',
}
const text = (wert) => (typeof wert === 'string' ? wert : '')
const istObjekt = (wert) => wert !== null && typeof wert === 'object' && !Array.isArray(wert)
let zaehler = 0
/** Ganze Zahl aus dem Feld: leer = null, nur Ziffern = Zahl, sonst der Text (der Server meldet den Fehler). */
const ganzeZahl = (eingabe) => {
const t = String(eingabe ?? '').trim()
if (t === '') return null
return /^\d{1,12}$/.test(t) ? Number(t) : t
}
const waehle = (paare, aktuell) => {
const sel = h('select', null, paare.map(([wert, label]) => h('option', { value: String(wert) }, label)))
sel.value = String(aktuell)
return sel
}
const kurzesFeld = (wert, extra) => {
const el = h('input', { type: 'text', autocomplete: 'off', ...extra })
el.value = wert
return el
}
const langesFeld = (wert, zeilen = 4) => {
const el = h('textarea', { rows: zeilen })
el.value = wert
return el
}
/** Einklappbarer Abschnitt (details); offen = beim Zeichnen aufgeklappt. */
const einklappbar = (titel, hinweis, offen, ...inhalt) => h('details', { class: 'kb-panel sh-abschnitt sh-einklappbar', open: offen },
h('summary', null, h('strong', null, titel)),
hinweis ? h('p', { class: 'sh-hinweis sh-abschnitt-text' }, hinweis) : null,
...inhalt)
/** Rohe Bytes hochladen (POST /api/shop/bild); Fehler tragen status und einen deutschen Text. */
async function ladeBildHoch(projectId, datei) {
let response
try {
response = await fetch(`/api/shop/bild?projectId=${encodeURIComponent(projectId)}`, {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/octet-stream' },
body: datei,
})
} catch {
throw Object.assign(new Error('Keine Verbindung. Bitte versuch es gleich noch einmal.'), { status: 0 })
}
const data = await response.json().catch(() => ({}))
if (!response.ok) {
throw Object.assign(new Error(text(data?.error) || BILD_FEHLER[response.status] || `Fehler ${response.status}`), { status: response.status })
}
return data
}
/* ------------------------------------------------------------------ Anzeige --- */
/** Preis fuer die Liste: bei Varianten „ab …“ (oder der eine Preis), sonst der Preis des Artikels. */
function preisAnzeige(p) {
const werte = Array.isArray(p?.varianten?.werte) ? p.varianten.werte : []
if (werte.length > 0) {
const preise = werte.map((w) => w.preisCent).filter(Number.isInteger)
if (preise.length === 0) return ''
const min = Math.min(...preise)
return min === Math.max(...preise) ? euroText(min) : `ab ${euroText(min)}`
}
return euroText(p?.preisCent)
}
/** Bestand fuer die Liste: „unbegrenzt“, „n Stück“ oder „ausverkauft“ (bei Varianten ueber alle Werte). */
function bestandAnzeige(p) {
const werte = Array.isArray(p?.varianten?.werte) ? p.varianten.werte : []
const stueck = (n) => (n === 0 ? 'ausverkauft' : `${n} Stück`)
if (werte.length > 0) {
if (werte.every((w) => w.bestand === 0)) return 'ausverkauft'
if (werte.some((w) => !Number.isInteger(w.bestand))) return 'unbegrenzt'
return stueck(werte.reduce((summe, w) => summe + w.bestand, 0))
}
return Number.isInteger(p?.bestand) ? stueck(p.bestand) : 'unbegrenzt'
}
/**
* Der Bestand, den der Haendler beim Laden gesehen hat (bestandGeladen am Artikel und je Variantenwert mit id). Der Server schreibt
* einen Bestand nur, wenn er sich davon unterscheidet; sonst bleibt der aktuelle Stand (zwischenzeitliche Verkaeufe gehen nicht verloren).
*/
const bestandOderNull = (wert) => (Number.isInteger(wert) ? wert : null)
/** Der Artikel in der Form, die der Server beim Speichern erwartet (aus der Listenansicht). */
function produktKoerper(p, ueberschreibe = {}) {
return {
name: p.name,
kategorie: p.kategorie,
kurz: p.kurz,
beschreibung: p.beschreibung,
preisCent: p.preisCent ?? null,
steuersatz: p.steuersatz,
grundpreis: p.grundpreis ?? null,
bestand: p.bestand ?? null,
bestandGeladen: bestandOderNull(p.bestand),
aktiv: p.aktiv,
varianten: istObjekt(p.varianten) && Array.isArray(p.varianten.werte)
? { ...p.varianten, werte: p.varianten.werte.map((w) => (typeof w?.id === 'string' ? { ...w, bestandGeladen: bestandOderNull(w.bestand) } : w)) }
: (p.varianten ?? null),
bilder: Array.isArray(p.bilder) ? p.bilder : [],
lieferart: p.lieferart,
widerruf: p.widerruf,
sicherheit: p.sicherheit,
lebensmittel: p.lebensmittel,
...ueberschreibe,
}
}
const leererArtikel = () => ({
name: '', kategorie: '', kurz: '', beschreibung: '', preisCent: null, steuersatz: 19, grundpreis: null, bestand: null, aktiv: true, varianten: null, bilder: [],
lieferart: 'beides', widerruf: 'gesetzlich', sicherheit: { hersteller: 'selbst', euVerantwortlicher: null, hinweise: '' }, lebensmittel: { aktiv: false, pflichtangaben: '' },
})
/* ---------------------------------------------------------------------- Reiter --- */
export async function render(panel, ctx) {
const api = ctx.request || request
const nurLesen = ctx.nurLesen === true
const projectId = ctx.projectId
const q = `projectId=${encodeURIComponent(projectId)}`
const noch = () => (typeof ctx.aktuell === 'function' ? ctx.aktuell() !== false : true)
const meldung = (t, art = 'ok') => ctx.zeigeMeldung?.(t, art)
const stand = { produkte: [], maxArtikel: 500, maxBilder: 4, kleinunternehmer: false }
let beschaeftigt = false
panel.replaceChildren(h('p', { class: 'sh-hinweis' }, 'Artikel werden geladen …'))
try {
const [liste, einstellungen] = await Promise.all([api(`/api/shop/produkte?${q}`), api(`/api/shop/einstellungen?${q}`).catch(() => null)])
if (!noch()) return
stand.produkte = Array.isArray(liste?.produkte) ? liste.produkte : []
if (Number.isInteger(liste?.maxArtikel)) stand.maxArtikel = liste.maxArtikel
if (Number.isInteger(liste?.maxBilder)) stand.maxBilder = liste.maxBilder
stand.kleinunternehmer = einstellungen?.einstellungen?.kleinunternehmer === true
} catch (err) {
if (noch()) panel.replaceChildren(fehlerKarte(err.message, () => render(panel, ctx)))
return
}
const vorschauUrl = (id) => `/api/shop/bild/${encodeURIComponent(projectId)}/${id}-s.webp`
/* ------------------------------------------------------------------ Liste --- */
function zeigeListe() {
if (!noch()) return
const anzahl = stand.produkte.length
const neuKnopf = nurLesen ? null : h('button', { type: 'button', class: 'kb-btn-primary', 'data-aktion': 'artikel-neu', onclick: () => zeigeEditor(null) }, 'Artikel anlegen')
if (anzahl === 0) {
panel.replaceChildren(abschnitt('Artikel', null,
h('p', { class: 'sh-leer' }, nurLesen ? 'Es gibt noch keine Artikel.' : 'Du hast noch keine Artikel. Lege deinen ersten Artikel an, dann kann er in deinem Shop erscheinen.'),
neuKnopf ? h('div', { class: 'sh-aktionen' }, neuKnopf) : null))
return
}
const zeilen = stand.produkte.map((p, i) => artikelZeile(p, i, anzahl))
panel.replaceChildren(abschnitt('Artikel', `${anzahl} von höchstens ${stand.maxArtikel} Artikeln. Mit „Hoch“ und „Runter“ bestimmst du die Reihenfolge im Shop.`,
h('ul', { class: 'sh-artikelliste' }, zeilen),
neuKnopf ? h('div', { class: 'sh-aktionen' }, neuKnopf) : null))
}
function artikelZeile(p, index, gesamt) {
const bild = Array.isArray(p.bildUrls) && p.bildUrls[0]?.klein ? p.bildUrls[0].klein : ''
const bestand = bestandAnzeige(p)
const info = h('div', { class: 'sh-artikel-info' },
h('strong', { class: 'sh-artikel-name' }, p.name || '(ohne Namen)'),
p.kategorie ? h('span', { class: 'sh-hinweis' }, p.kategorie) : null,
h('span', { class: 'sh-artikel-preis' }, preisAnzeige(p)),
h('span', { class: `sh-artikel-bestand${bestand === 'ausverkauft' ? ' sh-artikel-aus' : ''}`, 'data-bestand': '' }, bestand))
const li = h('li', { class: 'sh-artikel', 'data-artikel': p.id },
bild ? h('img', { class: 'sh-artikel-bild', src: bild, alt: '', width: 56, height: 56, loading: 'lazy' }) : h('span', { class: 'sh-artikel-bild sh-artikel-bild-leer', 'aria-hidden': 'true' }),
info)
if (nurLesen) {
li.append(h('span', { class: `sh-pille ${p.aktiv ? 'sh-pille-ok' : 'sh-pille-off'}` }, p.aktiv ? 'aktiv' : 'inaktiv'))
return li
}
const schalter = h('input', { type: 'checkbox', role: 'switch', 'aria-label': `${p.name} ist aktiv`, 'data-aktion': 'aktiv', checked: p.aktiv })
schalter.addEventListener('change', () => wechsleAktiv(p, schalter))
li.append(h('div', { class: 'sh-artikel-aktionen' },
h('label', { class: 'sh-schalter' }, schalter, h('span', null, 'aktiv')),
h('button', { type: 'button', class: 'kb-btn-outline', 'data-aktion': 'hoch', 'aria-label': `${p.name} nach oben`, disabled: index === 0, onclick: () => verschiebe(index, -1) }, 'Hoch'),
h('button', { type: 'button', class: 'kb-btn-outline', 'data-aktion': 'runter', 'aria-label': `${p.name} nach unten`, disabled: index === gesamt - 1, onclick: () => verschiebe(index, 1) }, 'Runter'),
h('button', { type: 'button', class: 'kb-btn-outline', 'data-aktion': 'bearbeiten', 'aria-label': `${p.name} bearbeiten`, onclick: () => zeigeEditor(p) }, 'Bearbeiten'),
h('button', { type: 'button', class: 'kb-btn-outline', 'data-aktion': 'loeschen', 'aria-label': `${p.name} löschen`, onclick: () => frageLoeschen(p, li) }, 'Löschen')))
return li
}
/** Fuehrt eine Aktion der Liste aus; waehrenddessen keine zweite. */
async function aktion(fn) {
if (beschaeftigt) return false
beschaeftigt = true
try {
await fn()
return true
} catch (err) {
meldung(err.message, 'fehler')
return false
} finally {
beschaeftigt = false
}
}
async function wechsleAktiv(p, schalter) {
const soll = schalter.checked
schalter.disabled = true
const ok = await aktion(async () => {
const antwort = await api(`/api/shop/produkte/${encodeURIComponent(p.id)}`, { method: 'PUT', body: { projectId, produkt: produktKoerper(p, { aktiv: soll }) } })
if (!noch()) return
stand.produkte = stand.produkte.map((x) => (x.id === p.id ? antwort.produkt : x))
meldung(soll ? `„${p.name}“ ist jetzt aktiv.` : `„${p.name}“ ist jetzt inaktiv.`)
})
if (!noch()) return
if (ok) zeigeListe()
else {
schalter.checked = !soll
schalter.disabled = false
}
}
async function verschiebe(index, richtung) {
const ids = stand.produkte.map((p) => p.id)
const ziel = index + richtung
if (ziel < 0 || ziel >= ids.length) return
;[ids[index], ids[ziel]] = [ids[ziel], ids[index]]
const ok = await aktion(async () => {
const antwort = await api('/api/shop/produkte/sortierung', { method: 'POST', body: { projectId, ids } })
if (noch()) stand.produkte = Array.isArray(antwort?.produkte) ? antwort.produkte : stand.produkte
})
if (ok && noch()) zeigeListe()
}
function frageLoeschen(p, li) {
li.querySelector('.sh-frage-box')?.remove()
const box = frage(`„${p.name}“ wirklich löschen?`,
h('p', { class: 'sh-hinweis' }, 'Der Artikel und seine Bilder werden gelöscht. Das kannst du nicht rückgängig machen. Bereits aufgegebene Bestellungen bleiben unverändert.'),
h('div', { class: 'sh-aktionen' },
h('button', { type: 'button', class: 'kb-btn-primary', 'data-aktion': 'loeschen-ja', onclick: () => loesche(p) }, 'Ja, löschen'),
h('button', { type: 'button', class: 'kb-btn-outline', 'data-aktion': 'loeschen-nein', onclick: () => box.remove() }, 'Abbrechen')))
li.append(box)
box.focus()
}
async function loesche(p) {
const ok = await aktion(async () => {
await api(`/api/shop/produkte/${encodeURIComponent(p.id)}?${q}`, { method: 'DELETE' })
if (!noch()) return
stand.produkte = stand.produkte.filter((x) => x.id !== p.id)
meldung(`„${p.name}“ wurde gelöscht.`)
})
if (ok && noch()) zeigeListe()
}
/* ----------------------------------------------------------------- Editor --- */
function zeigeEditor(vorhanden) {
if (!noch()) return
const id = vorhanden?.id || null
const p = vorhanden ? { ...leererArtikel(), ...vorhanden } : leererArtikel()
const si = istObjekt(p.sicherheit) ? p.sicherheit : {}
const andererHersteller = istObjekt(si.hersteller) ? si.hersteller : null
const eu = istObjekt(si.euVerantwortlicher) ? si.euVerantwortlicher : null
const lm = istObjekt(p.lebensmittel) ? p.lebensmittel : {}
const gp = istObjekt(p.grundpreis) ? p.grundpreis : null
zaehler += 1
/* Grunddaten */
const name = kurzesFeld(text(p.name))
const kategorienListe = `sh-kategorien-${zaehler}`
const kategorien = [...new Set(stand.produkte.map((x) => text(x.kategorie).trim()).filter(Boolean))]
const kategorie = kurzesFeld(text(p.kategorie), { list: kategorienListe })
const kategorieFeld = feld('kategorie', 'Kategorie', kategorie, 'Frei wählbar; im Shop werden die Artikel nach Kategorie gruppiert.')
kategorieFeld.append(h('datalist', { id: kategorienListe }, kategorien.map((k) => h('option', { value: k }))))
const kurz = kurzesFeld(text(p.kurz))
const beschreibung = langesFeld(text(p.beschreibung), 6)
/* Preis, Bestand */
const preis = euroEingabe({ cent: Number.isInteger(p.preisCent) ? p.preisCent : null })
const steuer = waehle(STEUERSAETZE.map((s) => [s, `${s} %`]), STEUERSAETZE.includes(p.steuersatz) ? p.steuersatz : 19)
const gpMenge = kurzesFeld(gp ? String(gp.menge).replace('.', ',') : '', { inputmode: 'decimal' })
const gpEinheit = waehle([['', 'kein Grundpreis'], ...EINHEITEN], gp ? gp.einheit : '')
const bestand = kurzesFeld(Number.isInteger(p.bestand) ? String(p.bestand) : '', { inputmode: 'numeric' })
/* Varianten */
const hatVarianten = istObjekt(p.varianten) && Array.isArray(p.varianten.werte) && p.varianten.werte.length > 0
const variantenAn = h('input', { type: 'checkbox', checked: hatVarianten })
const variantenName = kurzesFeld(hatVarianten ? text(p.varianten.name) : '')
const werte = hatVarianten
? p.varianten.werte.map((w) => ({ id: w.id, name: text(w.name), preis: centZuEuro(w.preisCent), bestand: Number.isInteger(w.bestand) ? String(w.bestand) : '', geladen: bestandOderNull(w.bestand) }))
: []
const werteBox = h('div', { class: 'sh-werte' })
const wertNeuKnopf = h('button', { type: 'button', class: 'kb-btn-outline', 'data-aktion': 'wert-neu', onclick: () => { werte.push({ id: undefined, name: '', preis: '', bestand: '' }); zeichneWerte() } }, 'Wert hinzufügen')
function zeichneWerte() {
werteBox.replaceChildren(...werte.map((w, i) => {
const wName = kurzesFeld(w.name)
const wPreis = euroEingabe({ cent: null })
wPreis.value = w.preis
const wBestand = kurzesFeld(w.bestand, { inputmode: 'numeric' })
for (const typ of ['input', 'change']) {
wName.addEventListener(typ, () => { w.name = wName.value })
wPreis.addEventListener(typ, () => { w.preis = wPreis.value })
wBestand.addEventListener(typ, () => { w.bestand = wBestand.value })
}
return h('div', { class: 'sh-wertzeile', 'data-wert': String(i) },
feld(`varianten.werte.${i}.name`, `Wert ${i + 1}`, wName),
feld(`varianten.werte.${i}.preisCent`, `Preis ${i + 1} in €`, wPreis),
feld(`varianten.werte.${i}.bestand`, `Bestand ${i + 1}`, wBestand, i === 0 ? 'Leer = unbegrenzt.' : undefined),
h('button', { type: 'button', class: 'kb-btn-outline', 'data-aktion': 'wert-weg', 'aria-label': `Wert ${i + 1} entfernen`, onclick: () => { werte.splice(i, 1); zeichneWerte() } }, 'Entfernen'))
}))
wertNeuKnopf.disabled = werte.length >= MAX_WERTE
}
zeichneWerte()
/* Bilder */
const bilder = (Array.isArray(p.bilder) ? p.bilder : []).map((bid) => ({ id: bid, klein: p.bildUrls?.find((b) => b.id === bid)?.klein || vorschauUrl(bid) }))
const bilderListe = h('ul', { class: 'sh-bilder' })
const bildFehler = h('p', { class: 'sh-fehler', 'data-fehler': 'bilder', role: 'alert' })
const bildStatus = h('p', { class: 'sh-hinweis', role: 'status', 'data-bild-status': '' })
const bildInput = h('input', { type: 'file', accept: BILD_TYPEN.join(','), 'aria-label': 'Bild hinzufügen' })
const bildFeld = h('div', { class: 'sh-feld' }, h('label', null, 'Bild hinzufügen'), bildInput)
let hochladen = false
let speichernKnopf = null
function zeichneBilder() {
bilderListe.replaceChildren(...bilder.map((b, i) => h('li', { class: 'sh-bild', 'data-bild': b.id },
h('img', { src: b.klein, alt: i === 0 ? 'Titelbild' : `Bild ${i + 1}`, width: 96, height: 96 }),
i === 0 ? h('span', { class: 'sh-pille sh-pille-ok' }, 'Titelbild') : null,
h('div', { class: 'sh-aktionen' },
i > 0 ? h('button', { type: 'button', class: 'kb-btn-outline', 'data-aktion': 'bild-titel', onclick: () => { bilder.unshift(...bilder.splice(i, 1)); zeichneBilder() } }, 'Als Titelbild') : null,
h('button', { type: 'button', class: 'kb-btn-outline', 'data-aktion': 'bild-weg', 'aria-label': `Bild ${i + 1} entfernen`, onclick: () => { bilder.splice(i, 1); zeichneBilder() } }, 'Entfernen')))))
bildFeld.hidden = bilder.length >= stand.maxBilder
bildInput.disabled = hochladen
bildStatus.textContent = hochladen ? 'Bild wird hochgeladen …' : ''
if (speichernKnopf) speichernKnopf.disabled = hochladen
}
bildInput.addEventListener('change', async () => {
const datei = bildInput.files?.[0]
bildInput.value = ''
if (!datei || hochladen) return
bildFehler.textContent = ''
if (!BILD_TYPEN.includes(datei.type)) {
bildFehler.textContent = BILD_FEHLER[415]
return
}
if (datei.size > BILD_MAX_BYTES) {
bildFehler.textContent = BILD_FEHLER[413]
return
}
hochladen = true
zeichneBilder()
try {
const antwort = await ladeBildHoch(projectId, datei)
if (typeof antwort?.imageId === 'string') bilder.push({ id: antwort.imageId, klein: vorschauUrl(antwort.imageId) })
} catch (err) {
bildFehler.textContent = err.message
} finally {
hochladen = false
zeichneBilder()
}
})
/* Lieferung, Widerruf */
const lieferart = waehle(LIEFERARTEN, LIEFERARTEN.some(([w]) => w === p.lieferart) ? p.lieferart : 'beides')
const widerruf = waehle(WIDERRUFE, WIDERRUFE.some(([w]) => w === p.widerruf) ? p.widerruf : 'gesetzlich')
/* Produktsicherheit */
const hersteller = waehle([['selbst', 'Ich bin der Hersteller'], ['andere', 'Ein anderer Hersteller']], andererHersteller ? 'andere' : 'selbst')
const hName = kurzesFeld(text(andererHersteller?.name))
const hAnschrift = kurzesFeld(text(andererHersteller?.anschrift))
const hKontakt = kurzesFeld(text(andererHersteller?.kontakt))
const herstellerBox = h('div', { class: 'sh-untergruppe', 'data-bereich': 'hersteller' },
feld('sicherheit.hersteller.name', 'Name des Herstellers', hName),
feld('sicherheit.hersteller.anschrift', 'Anschrift des Herstellers', hAnschrift),
feld('sicherheit.hersteller.kontakt', 'Kontakt des Herstellers (E-Mail oder Telefon)', hKontakt),
h('p', { class: 'sh-fehler', 'data-fehler': 'sicherheit.hersteller', role: 'alert' }))
const euAn = h('input', { type: 'checkbox', checked: eu !== null })
const euName = kurzesFeld(text(eu?.name))
const euAnschrift = kurzesFeld(text(eu?.anschrift))
const euKontakt = kurzesFeld(text(eu?.kontakt))
const euBox = h('div', { class: 'sh-untergruppe', 'data-bereich': 'eu' },
feld('sicherheit.euVerantwortlicher.name', 'Name der verantwortlichen Person in der EU', euName),
feld('sicherheit.euVerantwortlicher.anschrift', 'Anschrift in der EU', euAnschrift),
feld('sicherheit.euVerantwortlicher.kontakt', 'Kontakt in der EU (E-Mail oder Telefon)', euKontakt))
const hinweise = langesFeld(text(si.hinweise), 3)
/* Lebensmittel */
const lmAn = h('input', { type: 'checkbox', checked: lm.aktiv === true })
const pflicht = langesFeld(text(lm.pflichtangaben), 6)
const lmBox = h('div', { class: 'sh-untergruppe', 'data-bereich': 'lebensmittel' },
feld('lebensmittel.pflichtangaben', 'Pflichtangaben', pflicht, 'Bezeichnung, Zutaten, Allergene hervorgehoben, Menge, ggf. Hersteller'))
const aktivHaken = h('input', { type: 'checkbox', checked: p.aktiv !== false })
/* Bereiche ein- und ausblenden */
const einzelPreis = h('div', { class: 'sh-untergruppe', 'data-bereich': 'einzelpreis' }, feld('preisCent', 'Preis in € (brutto)', preis, 'Der Gesamtpreis, den deine Kundschaft bezahlt, inklusive Umsatzsteuer.'))
const steuerBox = h('div', { class: 'sh-untergruppe', 'data-bereich': 'steuersatz' }, feld('steuersatz', 'Steuersatz', steuer, 'Der Umsatzsteuersatz, der in diesem Preis enthalten ist.'))
steuerBox.hidden = stand.kleinunternehmer
const bestandAbschnitt = abschnitt('Bestand', null, feld('bestand', 'Bestand (Stück)', bestand, 'Leer lassen = unbegrenzt verfügbar. Bei 0 zeigt der Shop den Artikel als ausverkauft.'))
bestandAbschnitt.dataset.bereich = 'einzelbestand'
const variantenBox = h('div', { class: 'sh-untergruppe', 'data-bereich': 'varianten' },
feld('varianten.name', 'Name der Auswahl', variantenName, 'Zum Beispiel „Größe“ oder „Farbe“.'),
werteBox,
h('p', { class: 'sh-fehler', 'data-fehler': 'varianten.werte', role: 'alert' }),
h('div', { class: 'sh-aktionen' }, wertNeuKnopf),
h('p', { class: 'sh-hinweis' }, `Bis zu ${MAX_WERTE} Werte. Mit Varianten zählen nur der Preis und der Bestand der einzelnen Werte.`))
const zeigeBereiche = () => {
const an = variantenAn.checked
variantenBox.hidden = !an
einzelPreis.hidden = an
bestandAbschnitt.hidden = an
herstellerBox.hidden = hersteller.value !== 'andere'
euBox.hidden = !euAn.checked
lmBox.hidden = !lmAn.checked
}
variantenAn.addEventListener('change', () => {
if (variantenAn.checked && werte.length === 0) {
werte.push({ id: undefined, name: '', preis: '', bestand: '' })
zeichneWerte()
}
zeigeBereiche()
})
hersteller.addEventListener('change', zeigeBereiche)
euAn.addEventListener('change', zeigeBereiche)
lmAn.addEventListener('change', zeigeBereiche)
/* Artikel aus den Feldern lesen (der Server prueft; hier wird nichts vorab verworfen) */
function sammle() {
const mitVarianten = variantenAn.checked
const menge = gpMenge.value.trim()
const gpAn = menge !== '' || gpEinheit.value !== ''
return {
name: name.value,
kategorie: kategorie.value,
kurz: kurz.value,
beschreibung: beschreibung.value,
preisCent: mitVarianten ? null : preis.inCent(),
steuersatz: stand.kleinunternehmer ? 0 : Number(steuer.value),
grundpreis: gpAn ? { menge: /^\d+([.,]\d+)?$/.test(menge) ? Number(menge.replace(',', '.')) : menge, einheit: gpEinheit.value } : null,
bestand: mitVarianten ? null : ganzeZahl(bestand.value),
// Beim Bearbeiten: der beim Laden gesehene Bestand (der Server behaelt den aktuellen Stand, wenn er nicht geaendert wurde)
...(id ? { bestandGeladen: bestandOderNull(p.bestand) } : {}),
aktiv: aktivHaken.checked,
varianten: mitVarianten
? {
name: variantenName.value,
werte: werte.map((w) => ({
id: w.id,
name: w.name,
preisCent: euroZuCent(String(w.preis)),
bestand: ganzeZahl(w.bestand),
...(id && typeof w.id === 'string' ? { bestandGeladen: w.geladen } : {}),
})),
}
: null,
bilder: bilder.map((b) => b.id),
lieferart: lieferart.value,
widerruf: widerruf.value,
sicherheit: {
hersteller: hersteller.value === 'andere' ? { name: hName.value, anschrift: hAnschrift.value, kontakt: hKontakt.value } : 'selbst',
euVerantwortlicher: euAn.checked ? { name: euName.value, anschrift: euAnschrift.value, kontakt: euKontakt.value } : null,
hinweise: hinweise.value,
},
lebensmittel: { aktiv: lmAn.checked, pflichtangaben: pflicht.value },
}
}
const titel = h('h3', { tabindex: '-1' }, id ? 'Artikel bearbeiten' : 'Artikel anlegen')
speichernKnopf = h('button', { type: 'submit', class: 'kb-btn-primary', 'data-aktion': 'speichern' }, 'Speichern')
const form = h('form', { class: 'sh-artikel-editor', novalidate: true, 'aria-label': id ? 'Artikel bearbeiten' : 'Artikel anlegen' },
h('div', { class: 'kb-panel sh-abschnitt' }, titel,
h('p', { class: 'sh-fehler', 'data-fehler': '_', role: 'alert' }),
h('p', { class: 'sh-fehler', 'data-fehler': 'produkt', role: 'alert' })),
abschnitt('Grunddaten', null,
feld('name', 'Name', name),
kategorieFeld,
feld('kurz', 'Kurzbeschreibung', kurz, 'Höchstens 300 Zeichen, erscheint in der Artikelliste des Shops.'),
feld('beschreibung', 'Beschreibung', beschreibung, 'Höchstens 5000 Zeichen. Absätze sind erlaubt, HTML nicht.')),
abschnitt('Preis', null,
einzelPreis,
steuerBox,
h('div', { class: 'sh-grundpreis' },
feld('grundpreis.menge', 'Grundpreis: Menge', gpMenge, 'Nur bei Ware, die nach Gewicht, Volumen oder Länge verkauft wird, zum Beispiel 500 bei 500 g. Der Shop zeigt dann den Preis je 1 kg, 1 l, 1 m oder 1 m².'),
feld('grundpreis.einheit', 'Grundpreis: Einheit', gpEinheit))),
bestandAbschnitt,
abschnitt('Varianten', 'Optional: Wenn dein Artikel zum Beispiel in mehreren Größen mit eigenem Preis erhältlich ist.',
hakenFeld('variantenAn', variantenAn, 'Dieser Artikel hat Varianten'),
variantenBox),
abschnitt('Bilder', `Bis zu ${stand.maxBilder} Bilder (JPEG, PNG oder WebP, je höchstens 8 MB). Das erste Bild ist das Titelbild.`,
bilderListe,
bildFeld,
bildStatus,
bildFehler),
abschnitt('Lieferung', null, feld('lieferart', 'Lieferart', lieferart)),
abschnitt('Widerrufsrecht', null,
feld('widerruf', 'Widerrufsrecht für diesen Artikel', widerruf,
'Das ist deine eigene rechtliche Einschätzung als Verkäufer. Das gesetzliche Widerrufsrecht gilt, solange kein Ausnahmegrund (§ 312g Abs. 2 BGB) auf deine Ware zutrifft. WEBklar berät nicht rechtlich.')),
einklappbar('Produktsicherheit', 'Pflichtangaben nach der EU-Produktsicherheitsverordnung (GPSR): Wer stellt die Ware her, und worauf muss man achten?',
andererHersteller !== null || eu !== null || text(si.hinweise) !== '',
feld('sicherheit.herstellerArt', 'Hersteller', hersteller, 'Wenn du die Ware selbst herstellst, gelten deine Angaben aus den Einstellungen.'),
herstellerBox,
hakenFeld('sicherheit.ausserhalbEu', euAn, 'Der Hersteller sitzt außerhalb der EU', 'Dann brauchst du eine verantwortliche Person in der EU.'),
euBox,
feld('sicherheit.hinweise', 'Warn- und Sicherheitshinweise', hinweise, 'Zum Beispiel „Nicht für Kinder unter 3 Jahren geeignet“. Höchstens 2000 Zeichen.')),
einklappbar('Lebensmittel', 'Für Lebensmittel muss der Shop vor dem Kauf bestimmte Angaben zeigen (Lebensmittelinformationsverordnung).', lm.aktiv === true,
hakenFeld('lebensmittel.aktiv', lmAn, 'Dieser Artikel ist ein Lebensmittel'),
lmBox),
abschnitt('Sichtbarkeit', null, hakenFeld('aktiv', aktivHaken, 'Artikel ist aktiv', 'Nur aktive Artikel erscheinen im Shop.')),
h('div', { class: 'sh-aktionen' },
speichernKnopf,
h('button', { type: 'button', class: 'kb-btn-outline', 'data-aktion': 'abbrechen', onclick: () => zeigeListe() }, 'Abbrechen')))
// Fehlerhinweise in zugeklappten Bereichen sichtbar machen
const oeffneFehlerBereiche = () => {
for (const el of form.querySelectorAll('[data-fehler]')) if (el.textContent !== '') el.closest('details')?.setAttribute('open', '')
}
let sendet = false
form.addEventListener('submit', async (ev) => {
ev.preventDefault()
if (sendet || hochladen) return
setzeFehler(form, {})
bildFehler.textContent = ''
sendet = true
speichernKnopf.disabled = true
try {
const antwort = await api(id ? `/api/shop/produkte/${encodeURIComponent(id)}` : '/api/shop/produkte', { method: id ? 'PUT' : 'POST', body: { projectId, produkt: sammle() } })
if (!noch()) return
const gespeichert = antwort.produkt
stand.produkte = id ? stand.produkte.map((x) => (x.id === id ? gespeichert : x)) : [...stand.produkte, gespeichert]
meldung(id ? 'Der Artikel wurde gespeichert.' : 'Der Artikel wurde angelegt.')
zeigeListe()
} catch (err) {
if (!noch()) return
zeigeFehler(form, err)
oeffneFehlerBereiche()
} finally {
sendet = false
speichernKnopf.disabled = hochladen
}
})
zeichneBilder()
zeigeBereiche()
panel.replaceChildren(form)
titel.focus()
}
zeigeListe()
}
+479
View File
@@ -0,0 +1,479 @@
/* Webshop, Reiter „Bestellungen“ (Plan Block G, Task G5): zwei Ansichten, umschaltbar: „Bestellungen“ und „Widerrufe“.
- Bestellungen: Filter (Standard: bezahlt, noch nicht versendet) und Suche, Liste mit Blättern (GET /api/shop/bestellungen?projectId=&status=&suche=&seite=),
Klick auf eine Zeile öffnet das Detail (GET /api/shop/bestellungen/:id?projectId=): Positionen, Summen, Käufer, Adresse oder Abholung, Zahlung,
Verlauf, Erstattungen. Aktionen kommen aus `aktionen` der Antwort (POST /bestellungen/:id/aktion { projectId, aktion, versanddienst?, sendungsnummer? }),
Erstatten (POST /bestellungen/:id/erstatten { projectId, betragCent, grund? }) nur nach Rückfrage in der Seite. Darunter der CSV-Export
(GET /api/shop/bestellungen.csv?projectId=&von=&bis= als Link zum Herunterladen).
- Widerrufe: Liste (GET /api/shop/widerrufe?projectId=&seite=) mit „Als erledigt markieren“ (POST /widerrufe/:id/erledigt { projectId }).
Mit ctx.nurLesen (Shop abgeschaltet) gibt es keine Knöpfe zum Ändern, ausser ctx.abwickeln ist gesetzt (shop.js: Bestellungen und Widerrufe bleiben bearbeitbar). In der Kundenansicht antwortet der Server beim Ändern mit 403: der Text wird gezeigt.
render(panel, ctx): ctx = { projectId, status, nurLesen, aktuell(), neuLaden(), zeigeMeldung(text, art), request } (shop.js).
Fremdtext (Käufer, Artikel, Erklärung) kommt nur über textContent in die Seite. */
import { abschnitt, allgemeinerFehler, datumText, euroEingabe, euroText, feld, frage, h, zeigeFehler } from './shop-dom.js'
const MAIL_FEHLT = 'Gespeichert, aber die Mail an den Käufer ging nicht raus.'
const SEPA_HINWEIS = 'Lastschrift – versende erst, wenn das Geld sicher ist (nach einigen Tagen im Stripe-Dashboard sichtbar).'
const WIDERRUF_HINWEIS = 'Ein Widerruf ist erst erledigt, wenn du die Ware zurückhast oder erstattet hast (spätestens 14 Tage nach Eingang).'
const FEHLER_LADEN = 'Das konnte nicht geladen werden. Bitte versuch es noch einmal.'
const FILTER = [
['bezahlt', 'Bezahlt, noch zu versenden'],
['alle', 'Alle bezahlten Bestellungen'],
['versendet', 'Versendet'],
['abholbereit', 'Abholbereit'],
['abgeschlossen', 'Abgeschlossen'],
['erstattet', 'Erstattet'],
['offen', 'Zahlung offen'],
['storniert', 'Storniert'],
]
const STATUS = {
bezahlt: ['Bezahlt', 'wait'],
versendet: ['Versendet', 'ok'],
abholbereit: ['Abholbereit', 'ok'],
abgeschlossen: ['Abgeschlossen', 'ok'],
erstattet: ['Erstattet', 'off'],
offen: ['Zahlung offen', 'off'],
storniert: ['Storniert', 'bad'],
}
const AKTION_KNOPF = { versendet: 'Als versendet markieren', abholbereit: 'Als abholbereit markieren', abgeschlossen: 'Als abgeschlossen markieren', storniert: 'Stornieren' }
const ZAHLUNG = { card: 'Karte', klarna: 'Klarna', sepa_debit: 'SEPA-Lastschrift' }
const VERLAUF = {
angelegt: 'Bestellung angelegt',
bezahlt: 'Bezahlt',
versendet: 'Als versendet markiert',
abholbereit: 'Als abholbereit markiert',
abgeschlossen: 'Abgeschlossen',
teilerstattet: 'Teilweise erstattet',
erstattet: 'Vollständig erstattet',
storniert: 'Storniert',
abgelaufen: 'Reservierung abgelaufen',
betrag_abweichend: 'Gezahlter Betrag weicht von der Bestellung ab',
}
const istObjekt = (x) => x !== null && typeof x === 'object' && !Array.isArray(x)
const zeichen = (x) => (typeof x === 'string' ? x : '')
const cent = (x) => (Number.isInteger(x) && x >= 0 ? x : null)
const enc = encodeURIComponent
const EINFACHE_MAIL = /^[A-Za-z0-9._%+-]+@[A-Za-z0-9-]+(\.[A-Za-z0-9-]+)*\.[A-Za-z]{2,}$/
/** E-Mail als mailto-Link, aber nur bei einer einfachen Adresse; sonst als Text. */
const mailLink = (email) => (EINFACHE_MAIL.test(email) ? h('a', { href: `mailto:${email}` }, email) : email)
const statusPille = (status) => {
const [text, art] = STATUS[status] || [zeichen(status) || '?', 'off']
return h('span', { class: `sh-pille sh-pille-${art}`, 'data-status': status }, text)
}
const lieferartText = (art) => (art === 'abholung' ? 'Abholung' : art === 'versand' ? 'Versand' : zeichen(art))
/** Adresse als mehrzeiliger Text. */
function adresseText(a) {
if (!istObjekt(a)) return ''
return [a.name, a.line1, a.line2, [a.postal_code, a.city].filter((t) => zeichen(t)).join(' '), a.country].filter((t) => zeichen(t) !== '').join('\n')
}
const fakten = (...zeilen) => h('dl', { class: 'sh-fakten' }, zeilen.filter(Boolean).map(([name, wert]) => h('div', null, h('dt', null, name), h('dd', null, wert))))
export function render(panel, ctx) {
const wurzel = h('div', { class: 'sh-bestellungen' })
panel.replaceChildren(wurzel)
// Bei abgeschaltetem Shop bleiben Bestellungen, Erstattungen und Widerrufe bearbeitbar (ctx.abwickeln), alles andere ist nur lesbar
const nurLesen = ctx.nurLesen === true && ctx.abwickeln !== true
const z = { ansicht: 'bestellungen', filter: 'bezahlt', suche: '', seite: 1, widerrufSeite: 1, detail: null }
let lauf = 0 // nur die zuletzt gestartete Abfrage zeichnet
let beschaeftigt = false
const hier = () => wurzel.parentNode === panel && ctx.aktuell?.() !== false
const melde = (text, art = 'ok') => { if (hier()) ctx.zeigeMeldung?.(text, art) }
const get = (pfad) => ctx.request(pfad)
const post = (pfad, body) => ctx.request(pfad, { method: 'POST', body: { projectId: ctx.projectId, ...body } })
const inhalt = h('div', { class: 'sh-bestellungen-inhalt' })
const umschalter = h('div', { class: 'sh-umschalter', role: 'group', 'aria-label': 'Ansicht' }, [['bestellungen', 'Bestellungen'], ['widerrufe', 'Widerrufe']].map(([key, label]) => h('button', {
type: 'button', class: 'kb-btn-outline', 'data-ansicht': key, 'aria-pressed': 'false',
onclick: () => { melde(''); z.ansicht = key; zeichne() },
}, label)))
wurzel.append(umschalter, inhalt)
function zeichne() {
for (const k of umschalter.querySelectorAll('button')) k.setAttribute('aria-pressed', String(k.dataset.ansicht === z.ansicht))
if (z.ansicht === 'widerrufe') zeichneWiderrufe()
else if (z.detail) zeichneDetail()
else zeichneBestellungen()
}
/** Eine Abfrage mit Platzhalter; die Antwort zählt nur, wenn inzwischen keine neuere gestartet wurde und die Ansicht noch da ist. */
async function lade(ziel, pfad, fertig) {
const nr = ++lauf
ziel.replaceChildren(h('p', { class: 'kb-empty' }, 'Lade…'))
let antwort
try {
antwort = await get(pfad)
} catch (err) {
if (nr === lauf && hier()) ziel.replaceChildren(h('p', { class: 'sh-fehler', role: 'alert' }, err?.message || FEHLER_LADEN))
return
}
if (nr === lauf && hier()) fertig(antwort)
}
/** Aktion mit Sperre gegen Doppelklick; bei einem Fehler geht der Text an `form` (Feldfehler am Feld) oder als Meldung. Gibt die Antwort oder null. */
async function schreibe(pfad, body, form) {
if (beschaeftigt) return null
beschaeftigt = true
const knoepfe = [...inhalt.querySelectorAll('button')]
for (const k of knoepfe) k.setAttribute('disabled', '')
try {
return await post(pfad, body)
} catch (err) {
if (hier()) {
if (form) zeigeFehler(form, err)
else melde(err?.message || 'Das hat nicht geklappt. Bitte versuch es noch einmal.', 'fehler')
}
return null
} finally {
beschaeftigt = false
for (const k of knoepfe) k.removeAttribute('disabled')
}
}
/* ============================================================ Liste === */
function zeichneBestellungen() {
const select = h('select', { id: 'sh-b-filter' }, FILTER.map(([wert, label]) => {
const o = h('option', { value: wert }, label)
o.selected = wert === z.filter
return o
}))
select.addEventListener('change', () => { z.filter = select.value; z.seite = 1; ladeListe() })
const suchfeld = h('input', { type: 'search', id: 'sh-b-suche', maxlength: '100', autocomplete: 'off', placeholder: 'Nummer, Name oder E-Mail' })
suchfeld.value = z.suche
const werkzeug = h('form', {
class: 'sh-werkzeug',
novalidate: true,
role: 'search',
onsubmit: (ev) => {
ev.preventDefault()
z.suche = suchfeld.value.trim()
z.seite = 1
ladeListe()
},
},
feld('filter', 'Anzeigen', select),
feld('suche', 'Suche', suchfeld),
h('button', { type: 'submit', class: 'kb-btn-outline', 'data-aktion': 'suchen' }, 'Suchen'))
const box = h('div', { class: 'sh-liste-box', 'aria-live': 'polite' })
inhalt.replaceChildren(
abschnitt('Bestellungen', 'Ein Klick auf eine Bestellung zeigt alle Angaben und was du damit tun kannst.', werkzeug, box),
csvKarte(),
)
function ladeListe() {
const q = [`projectId=${enc(ctx.projectId)}`, `status=${enc(z.filter)}`]
if (z.suche) q.push(`suche=${enc(z.suche)}`)
q.push(`seite=${z.seite}`)
lade(box, `/api/shop/bestellungen?${q.join('&')}`, (antwort) => {
z.seite = Number.isInteger(antwort.seite) ? antwort.seite : z.seite
zeichneListe(box, antwort, ladeListe)
})
}
ladeListe()
}
function seitenLeiste(seite, mehr, setze) {
return h('div', { class: 'sh-seiten' },
h('button', { type: 'button', class: 'kb-btn-outline', 'data-aktion': 'zurueck', disabled: seite <= 1, onclick: () => setze(seite - 1) }, 'Zurück'),
h('span', null, `Seite ${seite}`),
h('button', { type: 'button', class: 'kb-btn-outline', 'data-aktion': 'weiter', disabled: !mehr, onclick: () => setze(seite + 1) }, 'Weiter'))
}
function zeichneListe(box, antwort, neuLaden) {
const liste = Array.isArray(antwort.bestellungen) ? antwort.bestellungen : []
const teile = []
if (!liste.length) {
teile.push(h('p', { class: 'kb-empty', 'data-leer': '' }, z.suche ? 'Zu dieser Suche gibt es keine Bestellung.' : 'In dieser Ansicht gibt es keine Bestellungen.'))
} else {
teile.push(h('ul', { class: 'sh-liste' }, liste.map((b) => h('li', null, h('button', {
type: 'button', class: 'sh-zeile', 'data-bestellung': b.id, onclick: () => oeffneDetail(b.id),
},
h('strong', { class: 'sh-zeile-nr' }, zeichen(b.bestellnummer)),
h('span', { class: 'sh-zeile-datum' }, datumText(b.bezahltAt || b.erstelltAt)),
h('span', { class: 'sh-zeile-name' }, zeichen(b.kaeuferName) || zeichen(b.kaeuferEmail)),
h('span', { class: 'sh-zeile-art' }, lieferartText(b.lieferart)),
h('span', { class: 'sh-zeile-summe' }, zeichen(b.summeText) || euroText(b.summeCent)),
statusPille(b.status),
b.ueberverkauft ? h('span', { class: 'sh-zeile-warnung' }, 'Bestand reichte nicht') : null)))))
}
if (antwort.abgeschnitten) teile.push(h('p', { class: 'sh-hinweis' }, 'Es werden nur die neuesten Bestellungen durchsucht. Für Älteres nimm den CSV-Export.'))
if (z.seite > 1 || antwort.mehr) teile.push(seitenLeiste(z.seite, antwort.mehr === true, (nr) => { z.seite = nr; neuLaden() }))
box.replaceChildren(...teile)
}
/* ============================================================ CSV === */
function csvKarte() {
const von = h('input', { type: 'date', id: 'sh-csv-von' })
const bis = h('input', { type: 'date', id: 'sh-csv-bis' })
const link = h('a', { class: 'kb-btn-outline', 'data-link': 'csv', download: '' }, 'CSV herunterladen')
const setzeLink = () => {
const q = [`projectId=${enc(ctx.projectId)}`]
if (von.value) q.push(`von=${enc(von.value)}`)
if (bis.value) q.push(`bis=${enc(bis.value)}`)
link.setAttribute('href', `/api/shop/bestellungen.csv?${q.join('&')}`)
}
for (const el of [von, bis]) for (const ereignis of ['input', 'change']) el.addEventListener(ereignis, setzeLink)
setzeLink()
return abschnitt('CSV-Export', 'Alle bezahlten Bestellungen eines Zeitraums für deine Buchhaltung. Ohne Datum bekommst du alle.',
h('div', { class: 'sh-werkzeug' }, feld('von', 'Von', von), feld('bis', 'Bis', bis), link))
}
/* ========================================================== Detail === */
async function oeffneDetail(id) {
melde('')
z.detail = { id, daten: null }
inhalt.replaceChildren(h('p', { class: 'kb-empty' }, 'Lade…'))
const nr = ++lauf
try {
const antwort = await get(`/api/shop/bestellungen/${enc(id)}?projectId=${enc(ctx.projectId)}`)
if (nr !== lauf || !hier()) return
z.detail = { id, daten: antwort.bestellung }
} catch (err) {
if (nr !== lauf || !hier()) return
z.detail = null
zeichne()
melde(err?.message || FEHLER_LADEN, 'fehler')
return
}
zeichne()
}
const zurueckZurListe = () => { melde(''); z.detail = null; zeichneBestellungen() }
function zeichneDetail() {
const b = z.detail?.daten
if (!istObjekt(b)) { z.detail = null; zeichneBestellungen(); return }
const summen = istObjekt(b.summen) ? b.summen : {}
const texte = istObjekt(summen.texte) ? summen.texte : {}
const kaeufer = istObjekt(b.kaeufer) ? b.kaeufer : {}
const aktionen = Array.isArray(b.aktionen) ? b.aktionen.filter((a) => AKTION_KNOPF[a]) : []
const teile = []
teile.push(h('button', { type: 'button', class: 'kb-btn-outline sh-zurueck', 'data-aktion': 'liste', onclick: zurueckZurListe }, '← Zur Liste'))
// Kopf: Nummer, Status, Zeiten, Hinweise
const hinweise = []
if (b.zahlungsart === 'sepa_debit' && b.status === 'bezahlt') hinweise.push(h('div', { class: 'sh-box sh-box-warn', role: 'note', 'data-hinweis': 'sepa' }, SEPA_HINWEIS))
if (b.ueberverkauft) hinweise.push(h('div', { class: 'sh-box sh-box-warn', role: 'note', 'data-hinweis': 'ueberverkauft' }, 'Achtung: Der Bestand hat für diese Bestellung nicht gereicht. Melde dich bitte beim Käufer oder erstatte die Bestellung.'))
if (b.spaet) hinweise.push(h('div', { class: 'sh-box sh-box-warn', role: 'note', 'data-hinweis': 'spaet' }, 'Die Zahlung kam erst nach Ablauf der Reservierung an. Prüfe, ob du noch liefern kannst.'))
teile.push(abschnitt(`Bestellung ${zeichen(b.bestellnummer)}`, null,
statusPille(b.status),
...hinweise,
fakten(
['Bestellt am', datumText(b.erstelltAt)],
b.bezahltAt ? ['Bezahlt am', datumText(b.bezahltAt)] : null,
['Gesamt', zeichen(b.summeText) || euroText(b.summeCent)],
cent(b.erstattetCent) ? ['Erstattet', `${zeichen(b.erstattetText) || euroText(b.erstattetCent)} (noch erstattbar: ${zeichen(b.restText) || euroText(b.restCent)})`] : null,
)))
// Positionen und Summen
const positionen = Array.isArray(b.positionen) ? b.positionen : []
teile.push(abschnitt('Artikel', null,
h('ul', { class: 'sh-positionen' }, positionen.map((p) => h('li', null,
h('span', null, `${Number.isInteger(p?.menge) ? p.menge : '?'} × ${zeichen(p?.name)}${zeichen(p?.varianteText) ? ` (${p.varianteText})` : ''}`),
h('span', null, zeichen(p?.summeText) || euroText(p?.summeCent))))),
fakten(
['Zwischensumme', zeichen(texte.zwischensumme) || euroText(summen.zwischensummeCent)],
['Versand', zeichen(texte.versand) || euroText(summen.versandCent)],
['Gesamt', zeichen(texte.gesamt) || euroText(summen.gesamtCent)],
zeichen(texte.steuerHinweis) ? ['Steuer', texte.steuerHinweis] : null,
)))
// Käufer, Lieferung, Zahlung
const lieferung = b.lieferart === 'abholung'
? ['Abholung', [zeichen(b.abholung?.adresse), zeichen(b.abholung?.hinweis)].filter(Boolean).join('\n') || 'Der Käufer holt die Ware bei dir ab.']
: ['Lieferadresse', adresseText(b.lieferadresse) || 'Noch keine Adresse (die Zahlung ist noch nicht eingegangen).']
teile.push(abschnitt('Käufer und Lieferung', null, fakten(
['Name', zeichen(kaeufer.name)],
['E-Mail', mailLink(zeichen(kaeufer.email))],
['Lieferart', `${lieferartText(b.lieferart)}${b.lieferart === 'versand' && zeichen(b.land) ? ` (${b.land})` : ''}`],
lieferung,
b.rechnungsadresse && adresseText(b.rechnungsadresse) !== adresseText(b.lieferadresse) ? ['Rechnungsadresse', adresseText(b.rechnungsadresse)] : null,
zeichen(b.lieferzeit) ? ['Lieferzeit laut Shop', b.lieferzeit] : null,
['Zahlungsart', ZAHLUNG[b.zahlungsart] || zeichen(b.zahlungsart) || 'Nicht bekannt'],
istObjekt(b.versand) ? ['Versand', [zeichen(b.versand.versanddienst), zeichen(b.versand.sendungsnummer)].filter(Boolean).join(', ') || 'Versendet'] : null,
)))
// Aktionen
if (!nurLesen && (aktionen.length || b.kannErstatten)) teile.push(aktionenKarte(b, aktionen))
// Verlauf und Erstattungen
const verlauf = Array.isArray(b.verlauf) ? b.verlauf.filter(istObjekt) : []
teile.push(abschnitt('Verlauf', null, verlauf.length
? h('ul', { class: 'sh-verlauf' }, verlauf.map((v) => h('li', null,
h('span', { class: 'sh-verlauf-zeit' }, datumText(v.at)),
h('span', null, verlaufText(v))))) : h('p', { class: 'sh-hinweis' }, 'Noch nichts passiert.')))
const erstattungen = Array.isArray(b.erstattungen) ? b.erstattungen.filter(istObjekt) : []
if (erstattungen.length) {
teile.push(abschnitt('Erstattungen', null, h('ul', { class: 'sh-verlauf', 'data-liste': 'erstattungen' }, erstattungen.map((e) => h('li', null,
h('span', { class: 'sh-verlauf-zeit' }, datumText(e.at)),
h('span', null, `${euroText(e.betragCent)}${zeichen(e.grund) ? `: ${e.grund}` : ''}`))))))
}
inhalt.replaceChildren(...teile)
}
function verlaufText(v) {
const text = VERLAUF[v.was] || zeichen(v.was)
const extra = []
if (zeichen(v.versanddienst)) extra.push(v.versanddienst)
if (zeichen(v.sendungsnummer)) extra.push(v.sendungsnummer)
if (cent(v.betragCent) !== null && (v.was === 'teilerstattet' || v.was === 'erstattet')) extra.push(euroText(v.betragCent))
return extra.length ? `${text} (${extra.join(', ')})` : text
}
/* ======================================================== Aktionen === */
function aktionenKarte(b, aktionen) {
const bereich = h('div', { class: 'sh-aktionen-bereich' })
const knoepfe = h('div', { class: 'sh-aktionen' }, aktionen.map((aktion) => h('button', {
type: 'button',
class: aktion === 'storniert' ? 'kb-btn-outline' : 'kb-btn-primary',
'data-aktion': aktion,
onclick: () => {
if (aktion === 'versendet') zeigeVersandFrage(b, bereich)
else if (aktion === 'storniert') zeigeStornoFrage(b, bereich)
else fuehreAktionAus(b, aktion, {}, null)
},
}, AKTION_KNOPF[aktion])))
const teile = [knoepfe, bereich]
if (b.kannErstatten) teile.push(erstattenBereich(b))
return abschnitt('Aktionen', 'Der Käufer bekommt zu „versendet“, „abholbereit“ und zu Erstattungen eine Mail von deinem Shop.', ...teile)
}
const stelleFrage = (bereich, form) => { bereich.replaceChildren(form); form.focus() }
function zeigeVersandFrage(b, bereich) {
const dienst = h('input', { type: 'text', maxlength: '60', autocomplete: 'off', placeholder: 'z. B. DHL' })
const nummer = h('input', { type: 'text', maxlength: '80', autocomplete: 'off' })
const form = frage('Als versendet markieren',
feld('versanddienst', 'Versanddienst (freiwillig)', dienst),
feld('sendungsnummer', 'Sendungsnummer (freiwillig)', nummer, 'Beides steht in der Mail an den Käufer.'),
allgemeinerFehler(),
h('div', { class: 'sh-aktionen' },
h('button', { type: 'submit', class: 'kb-btn-primary', 'data-aktion': 'versendet-speichern' }, 'Als versendet speichern'),
h('button', { type: 'button', class: 'kb-btn-outline', 'data-aktion': 'abbrechen', onclick: () => bereich.replaceChildren() }, 'Abbrechen')))
form.addEventListener('submit', () => fuehreAktionAus(b, 'versendet', { versanddienst: dienst.value.trim(), sendungsnummer: nummer.value.trim() }, form))
stelleFrage(bereich, form)
}
function zeigeStornoFrage(b, bereich) {
const form = frage('Bestellung wirklich stornieren?',
h('p', { class: 'sh-hinweis' }, 'Die Bestellung ist noch nicht bezahlt. Nach dem Stornieren kann sie nicht mehr bezahlt werden.'),
allgemeinerFehler(),
h('div', { class: 'sh-aktionen' },
h('button', { type: 'submit', class: 'kb-btn-primary', 'data-aktion': 'stornieren-bestaetigen' }, 'Ja, stornieren'),
h('button', { type: 'button', class: 'kb-btn-outline', 'data-aktion': 'abbrechen', onclick: () => bereich.replaceChildren() }, 'Abbrechen')))
form.addEventListener('submit', () => fuehreAktionAus(b, 'storniert', {}, form))
stelleFrage(bereich, form)
}
async function fuehreAktionAus(b, aktion, extra, form) {
const antwort = await schreibe(`/api/shop/bestellungen/${enc(b.id)}/aktion`, { aktion, ...extra }, form)
if (!antwort || !hier()) return
if (istObjekt(antwort.bestellung)) z.detail = { id: b.id, daten: antwort.bestellung }
zeichne()
if (antwort.mailOk === false) { melde(MAIL_FEHLT, 'fehler'); return }
const text = {
versendet: 'Als versendet gespeichert. Der Käufer hat eine Mail bekommen.',
abholbereit: 'Als abholbereit gespeichert. Der Käufer hat eine Mail bekommen.',
abgeschlossen: 'Als abgeschlossen gespeichert.',
storniert: 'Die Bestellung ist storniert.',
}[aktion]
melde(text || 'Gespeichert.')
}
/** Erstatten: Formular (Betrag, Grund) und darunter die Rückfrage; beides in einem Container, weil Formulare sich nicht verschachteln lassen. */
function erstattenBereich(b) {
const rest = cent(b.restCent) ?? 0
const betrag = euroEingabe({ cent: rest, id: 'sh-erstatten-betrag', label: 'Betrag in Euro' })
const grund = h('input', { type: 'text', maxlength: '300', autocomplete: 'off' })
const bereich = h('div', { class: 'sh-erstatten-frage' })
const form = h('form', {
class: 'sh-form',
novalidate: true,
'data-form': 'erstatten',
onsubmit: (ev) => {
ev.preventDefault()
const c = betrag.inCent()
const fehler = {}
if (c === null) fehler.betrag = 'Bitte gib einen Betrag in Euro an, z. B. 12,50.'
else if (c < 1) fehler.betrag = 'Der Betrag muss größer als 0 sein.'
else if (c > rest) fehler.betrag = `Es sind höchstens ${euroText(rest)} erstattbar.`
if (Object.keys(fehler).length) { zeigeFehler(form, { fehler, message: '' }); return }
zeigeFehler(form, { fehler: {}, message: '' })
zeigeErstattenFrage(b, c, grund.value.trim(), bereich, form)
},
},
h('h4', null, 'Erstatten'),
h('p', { class: 'sh-hinweis' }, `Noch erstattbar: ${euroText(rest)}. Das Geld geht über dein Stripe-Konto an den Käufer zurück.`),
feld('betrag', 'Betrag in Euro', betrag),
feld('grund', 'Grund (freiwillig)', grund, 'Steht in der Mail an den Käufer.'),
allgemeinerFehler(),
h('div', { class: 'sh-aktionen' }, h('button', { type: 'submit', class: 'kb-btn-outline', 'data-aktion': 'erstatten' }, 'Erstatten …')))
return h('div', { class: 'sh-erstatten' }, form, bereich)
}
function zeigeErstattenFrage(b, betragCent, grund, bereich, form) {
const frageForm = frage(`Wirklich ${euroText(betragCent)} an den Käufer zurückzahlen? Das geht nicht rückgängig.`,
h('div', { class: 'sh-aktionen' },
h('button', { type: 'submit', class: 'kb-btn-primary', 'data-aktion': 'erstatten-bestaetigen' }, 'Ja, zurückzahlen'),
h('button', { type: 'button', class: 'kb-btn-outline', 'data-aktion': 'abbrechen', onclick: () => bereich.replaceChildren() }, 'Abbrechen')))
frageForm.addEventListener('submit', async () => {
const antwort = await schreibe(`/api/shop/bestellungen/${enc(b.id)}/erstatten`, { betragCent, ...(grund ? { grund } : {}) }, form)
if (!hier()) return
if (!antwort) { bereich.replaceChildren(); return }
if (istObjekt(antwort.bestellung)) z.detail = { id: b.id, daten: antwort.bestellung }
zeichne()
if (antwort.mailOk === false) { melde(MAIL_FEHLT, 'fehler'); return }
melde(`${euroText(cent(antwort.erstattung?.betragCent) ?? betragCent)} erstattet.${antwort.voll ? ' Die Bestellung ist damit vollständig erstattet.' : ''} Der Käufer hat eine Mail bekommen.`)
})
stelleFrage(bereich, frageForm)
}
/* ======================================================= Widerrufe === */
function zeichneWiderrufe() {
const box = h('div', { class: 'sh-liste-box', 'aria-live': 'polite' })
inhalt.replaceChildren(abschnitt('Widerrufe', WIDERRUF_HINWEIS, box))
const ladeW = () => lade(box, `/api/shop/widerrufe?projectId=${enc(ctx.projectId)}&seite=${z.widerrufSeite}`, (antwort) => {
z.widerrufSeite = Number.isInteger(antwort.seite) ? antwort.seite : z.widerrufSeite
const liste = Array.isArray(antwort.widerrufe) ? antwort.widerrufe : []
const teile = []
if (!liste.length) teile.push(h('p', { class: 'kb-empty', 'data-leer': '' }, 'Es ist noch kein Widerruf eingegangen.'))
else teile.push(h('ul', { class: 'sh-liste' }, liste.map((w) => widerrufZeile(w, ladeW))))
if (z.widerrufSeite > 1 || antwort.mehr) teile.push(seitenLeiste(z.widerrufSeite, antwort.mehr === true, (nr) => { z.widerrufSeite = nr; ladeW() }))
box.replaceChildren(...teile)
})
ladeW()
}
function widerrufZeile(w, neuLaden) {
const erledigt = Boolean(w.bearbeitetAt)
return h('li', { class: 'sh-widerruf', 'data-widerruf': w.id },
fakten(
['Eingang', datumText(w.eingangAt)],
['Name', zeichen(w.name)],
['E-Mail', mailLink(zeichen(w.email))],
['Bestellung', zeichen(w.bestellnummer) || 'Nicht zugeordnet'],
['Erklärung', zeichen(w.nachricht) || '–'],
['Stand', erledigt ? `erledigt am ${datumText(w.bearbeitetAt)}` : 'offen'],
),
h('div', { class: 'sh-aktionen' },
w.orderId ? h('button', { type: 'button', class: 'kb-btn-outline', 'data-aktion': 'zur-bestellung', onclick: () => { z.ansicht = 'bestellungen'; oeffneDetail(w.orderId) } }, 'Zur Bestellung') : null,
!erledigt && !nurLesen ? h('button', {
type: 'button',
class: 'kb-btn-primary',
'data-aktion': 'erledigt',
onclick: async () => {
const antwort = await schreibe(`/api/shop/widerrufe/${enc(w.id)}/erledigt`, {}, null)
if (!antwort || !hier()) return
melde('Der Widerruf ist als erledigt markiert.')
neuLaden()
},
}, 'Als erledigt markieren') : null))
}
zeichne()
}
+265
View File
@@ -0,0 +1,265 @@
/* Webshop, Reiter „Einstellungen“ (Plan Block G, Task G6): Shopname und Akzentfarbe, Anbieter, Steuer, Versand, Abholung, Lieferzeit,
Mindestbestellwert, Ruecksendekosten und Zahlungsarten.
Schnittstelle (server/routes/shop.js): GET /api/shop/einstellungen?projectId= => { einstellungen, version, vollstaendig } und
PUT /api/shop/einstellungen { projectId, einstellungen, version } => dasselbe; ein Konflikt (409, EINSTELLUNG_VERALTET) heisst: jemand
hat zwischendurch gespeichert, dann zeigt der Reiter den Text und „Neu laden“. Geprueft wird auf dem Server (server/services/shop/
einstellungen.js); dessen Feldfehler tragen Pfade wie 'anbieter.plz' oder 'versand.kostenCent.AT' und stehen am Feld (data-feld).
Betraege werden hier in ganze Cent umgerechnet (euroEingabe), unlesbare Betraege werden schon im Browser gemeldet.
render(panel, ctx): ctx = { projectId, nurLesen, zeigeMeldung(text, art), neuLaden(), request } (siehe shop.js).
Text kommt nur ueber textContent in die Seite (h), nie ueber innerHTML. */
import { abschnitt, allgemeinerFehler, centZuEuro, euroEingabe, feld, h, hakenFeld, request, setzeFehler, zeigeFehler } from './shop-dom.js'
const LAENDER = [['DE', 'Deutschland'], ['AT', 'Österreich']]
const VERSAND_ZIELE = [['DE', 'Nur Deutschland'], ['DE,AT', 'Deutschland und Österreich']]
const RUECKSENDUNG = [['kaeufer', 'Der Käufer trägt die Kosten der Rücksendung'], ['haendler', 'Ich trage die Kosten der Rücksendung']]
export const SEPA_WARNUNG = 'Bei Lastschrift ist das Geld erst nach einigen Tagen sicher. Versende erst, wenn die Bestellung als bezahlt markiert ist.'
export const STRIPE_HINWEIS = 'Schalte Klarna oder Lastschrift nur ein, wenn sie in deinem Stripe-Konto aktiviert sind.'
export const PAYPAL_HINWEIS = 'PayPal ist über Stripe für Shops wie deinen leider nicht möglich.'
export const ANBIETER_HINWEIS = 'Diese Angaben erscheinen im Shop, in der Bestellbestätigung und in der Widerrufsbelehrung.'
const text = (wert) => (typeof wert === 'string' ? wert : '')
const textfeld = (optionen = {}) => h('input', { type: 'text', autocomplete: 'off', ...optionen })
const auswahl = (liste) => h('select', null, liste.map(([wert, name]) => h('option', { value: wert }, name)))
export async function render(panel, ctx = {}) {
const projectId = ctx.projectId
const aufruf = ctx.request || request
const nurLesen = ctx.nurLesen === true
const antwort = await aufruf(`/api/shop/einstellungen?projectId=${encodeURIComponent(projectId)}`)
let version = Number.isInteger(antwort?.version) ? antwort.version : 0
/* ----------------------------------------------------------------- Felder --- */
const shopname = textfeld({ maxlength: 80 })
const akzent = h('input', { type: 'color' })
const anbieterName = textfeld({ maxlength: 120, autocomplete: 'organization' })
const strasse = textfeld({ maxlength: 120, autocomplete: 'street-address' })
const plz = textfeld({ maxlength: 10, inputmode: 'numeric', autocomplete: 'postal-code' })
const ort = textfeld({ maxlength: 80, autocomplete: 'address-level2' })
const land = auswahl(LAENDER)
const email = h('input', { type: 'email', maxlength: 254, autocomplete: 'email' })
const telefon = h('input', { type: 'tel', maxlength: 40, autocomplete: 'tel' })
const ustId = textfeld({ maxlength: 20, placeholder: 'DE123456789' })
// Steuerart: keine Vorauswahl, der Haendler muss sich entscheiden (null = noch nicht gewaehlt)
const steuerJa = h('input', { type: 'radio', name: 'sh-kleinunternehmer', value: 'ja', required: true })
const steuerNein = h('input', { type: 'radio', name: 'sh-kleinunternehmer', value: 'nein', required: true })
steuerJa.dataset.feld = 'kleinunternehmer'
steuerNein.dataset.feld = 'kleinunternehmer'
const versandAktiv = h('input', { type: 'checkbox' })
const laender = auswahl(VERSAND_ZIELE)
const kostenDE = euroEingabe()
const kostenAT = euroEingabe()
const freiAb = euroEingabe()
const abholungAktiv = h('input', { type: 'checkbox' })
const abholAdresse = h('textarea', { rows: 3, maxlength: 300 })
const abholHinweis = h('textarea', { rows: 3, maxlength: 300 })
const lieferzeit = textfeld({ maxlength: 80, placeholder: '2–4 Werktage' })
const mindest = euroEingabe()
const rueck = auswahl(RUECKSENDUNG)
// Karte (mit Apple Pay und Google Pay) ist immer dabei und nicht abwaehlbar
const karte = h('input', { type: 'checkbox', disabled: true })
karte.checked = true
const klarna = h('input', { type: 'checkbox' })
const sepa = h('input', { type: 'checkbox' })
/* ----------------------------------------------------------- Aufbau/Fuellen --- */
const versandAt = h('div', { class: 'sh-versand-at' }, feld('versand.kostenCent.AT', 'Versandkosten Österreich (€)', kostenAT))
const versandDetails = h('div', { class: 'sh-versand-details' },
feld('versand.laender', 'Wohin lieferst du?', laender),
feld('versand.kostenCent.DE', 'Versandkosten Deutschland (€)', kostenDE),
versandAt,
feld('versand.freiAbCent', 'Versandkostenfrei ab (€, freiwillig)', freiAb, 'Ab diesem Warenwert zahlen deine Kunden keinen Versand. Leer lassen, wenn es das nicht geben soll.'))
const abholDetails = h('div', { class: 'sh-abhol-details' },
feld('abholung.adresse', 'Abholadresse', abholAdresse, 'Hier können deine Kunden die Ware abholen.'),
feld('abholung.hinweis', 'Hinweis zur Abholung (freiwillig)', abholHinweis, 'Zum Beispiel deine Öffnungszeiten.'))
const zeigeTeile = () => {
versandDetails.hidden = !versandAktiv.checked
versandAt.hidden = laender.value !== 'DE,AT'
abholDetails.hidden = !abholungAktiv.checked
}
/** Felder aus einem Stand des Servers fuellen (Standardwerte fuer alles, was fehlt). */
function fuelle(e) {
const s = e && typeof e === 'object' ? e : {}
const a = s.anbieter || {}
const v = s.versand || {}
const ab = s.abholung || {}
const kosten = v.kostenCent || {}
shopname.value = text(s.shopname)
akzent.value = /^#[0-9a-fA-F]{6}$/.test(text(s.akzent)) ? s.akzent : '#111111'
anbieterName.value = text(a.name)
strasse.value = text(a.strasse)
plz.value = text(a.plz)
ort.value = text(a.ort)
land.value = a.land === 'AT' ? 'AT' : 'DE'
email.value = text(a.email)
telefon.value = text(a.telefon)
ustId.value = text(a.ustId)
steuerJa.checked = s.kleinunternehmer === true
steuerNein.checked = s.kleinunternehmer === false
versandAktiv.checked = v.aktiv !== false
laender.value = Array.isArray(v.laender) && v.laender.includes('AT') ? 'DE,AT' : 'DE'
kostenDE.value = centZuEuro(kosten.DE)
kostenAT.value = centZuEuro(kosten.AT)
freiAb.value = centZuEuro(v.freiAbCent)
abholungAktiv.checked = ab.aktiv === true
abholAdresse.value = text(ab.adresse)
abholHinweis.value = text(ab.hinweis)
lieferzeit.value = text(s.lieferzeit)
mindest.value = centZuEuro(s.mindestbestellCent)
rueck.value = s.ruecksendekosten === 'haendler' ? 'haendler' : 'kaeufer'
const zahl = Array.isArray(s.zahlungsarten) ? s.zahlungsarten : ['card']
klarna.checked = zahl.includes('klarna')
sepa.checked = zahl.includes('sepa_debit')
zeigeTeile()
}
fuelle(antwort?.einstellungen)
versandAktiv.addEventListener('change', zeigeTeile)
laender.addEventListener('change', zeigeTeile)
abholungAktiv.addEventListener('change', zeigeTeile)
const speichern = h('button', { type: 'submit', class: 'kb-btn-primary', 'data-aktion': 'einstellungen-speichern' }, 'Einstellungen speichern')
const konfliktPlatz = h('div', { 'data-konflikt-platz': '' })
const zeigeKonflikt = (meldung) => konfliktPlatz.replaceChildren(h('div', { class: 'sh-box sh-box-warn', role: 'alert', 'data-konflikt': '' },
h('span', null, meldung),
h('button', {
type: 'button',
class: 'kb-btn-outline',
'data-aktion': 'neu-laden',
onclick: () => { render(panel, ctx).catch((err) => panel.replaceChildren(h('p', { class: 'sh-fehler', role: 'alert' }, err.message))) },
}, 'Neu laden')))
const form = h('form', { class: 'sh-form sh-einstellungen', novalidate: true, 'aria-label': 'Einstellungen des Webshops' },
abschnitt('Shop', null,
feld('shopname', 'Shopname', shopname, 'So heißt dein Shop. Er steht im Shop und in den E-Mails an deine Kunden.'),
feld('akzent', 'Akzentfarbe', akzent, 'Diese Farbe bekommen Knöpfe und Akzente in deinem Shop.')),
abschnitt('Anbieter', ANBIETER_HINWEIS,
feld('anbieter.name', 'Name oder Firma', anbieterName),
feld('anbieter.strasse', 'Straße und Hausnummer', strasse),
feld('anbieter.plz', 'Postleitzahl', plz),
feld('anbieter.ort', 'Ort', ort),
feld('anbieter.land', 'Land', land),
feld('anbieter.email', 'E-Mail-Adresse', email),
feld('anbieter.telefon', 'Telefon (freiwillig)', telefon),
feld('anbieter.ustId', 'USt-IdNr. (freiwillig)', ustId, 'Zum Beispiel DE123456789. Lass das Feld leer, wenn du keine hast.')),
abschnitt('Steuer', 'Pflichtangabe: Bitte wähle, wie bei dir die Umsatzsteuer behandelt wird. Ohne diese Angabe kannst du den Shop nicht öffnen.',
h('fieldset', { class: 'sh-feld sh-radiogruppe', 'aria-required': 'true' },
h('legend', null, 'Umsatzsteuer'),
h('label', { class: 'sh-haken' }, steuerJa, h('span', null, 'Ich bin Kleinunternehmer nach § 19 UStG', h('small', { class: 'sh-hinweis' }, 'Dann steht im Shop der Hinweis, dass keine Umsatzsteuer berechnet wird.'))),
h('label', { class: 'sh-haken' }, steuerNein, h('span', null, 'Ich weise Umsatzsteuer aus', h('small', { class: 'sh-hinweis' }, 'Dann gelten deine Preise inklusive Umsatzsteuer; den Steuersatz wählst du je Artikel.'))),
h('p', { class: 'sh-fehler', 'data-fehler': 'kleinunternehmer', role: 'alert' }))),
abschnitt('Versand', null,
hakenFeld('versand.aktiv', versandAktiv, 'Ich versende Bestellungen'),
versandDetails),
abschnitt('Abholung', null,
hakenFeld('abholung.aktiv', abholungAktiv, 'Kunden können Bestellungen abholen'),
abholDetails),
abschnitt('Lieferzeit und Rücksendung', null,
feld('lieferzeit', 'Lieferzeit', lieferzeit, 'Pflichtangabe, zum Beispiel „2–4 Werktage“. Die Angabe steht im Shop und in der Bestellbestätigung.'),
feld('mindestbestellCent', 'Mindestbestellwert (€, freiwillig)', mindest, 'Leer lassen, wenn es keinen Mindestbestellwert geben soll.'),
feld('ruecksendekosten', 'Rücksendekosten bei Widerruf', rueck)),
abschnitt('Zahlungsarten', 'Dein Shop zeigt genau diese Zahlungsarten an.',
hakenFeld('zahlung.card', karte, 'Karte (inklusive Apple Pay und Google Pay)', 'Immer dabei.'),
hakenFeld('zahlung.klarna', klarna, 'Klarna'),
hakenFeld('zahlung.sepa_debit', sepa, 'SEPA-Lastschrift'),
h('div', { class: 'sh-box sh-box-warn', role: 'note', 'data-hinweis': 'sepa' }, SEPA_WARNUNG),
h('p', { class: 'sh-hinweis', 'data-hinweis': 'stripe' }, STRIPE_HINWEIS),
h('p', { class: 'sh-hinweis', 'data-hinweis': 'paypal' }, PAYPAL_HINWEIS),
h('p', { class: 'sh-fehler', 'data-fehler': 'zahlungsarten', role: 'alert' })),
allgemeinerFehler(),
konfliktPlatz,
nurLesen ? null : h('div', { class: 'sh-aktionen' }, speichern))
/* ------------------------------------------------------------ Einsammeln --- */
/** Eingabe => { einstellungen } oder { fehler } (nur was der Browser selbst nicht lesen kann: Betraege). */
function sammle() {
const fehler = {}
const betrag = (steuer, name, { pflicht = false } = {}) => {
if (!steuer.istLesbar()) {
fehler[name] = 'Bitte gib einen Betrag wie 4,90 an.'
return null
}
const cent = steuer.inCent()
if (cent === null && pflicht) fehler[name] = 'Bitte gib die Versandkosten an. Für kostenlosen Versand trage 0,00 ein.'
return cent
}
const mitAt = laender.value === 'DE,AT'
const aktiv = versandAktiv.checked
const kostenCent = { DE: betrag(kostenDE, 'versand.kostenCent.DE', { pflicht: aktiv }) }
if (mitAt) kostenCent.AT = betrag(kostenAT, 'versand.kostenCent.AT', { pflicht: aktiv })
const freiAbCent = betrag(freiAb, 'versand.freiAbCent')
const mindestbestellCent = betrag(mindest, 'mindestbestellCent')
if (Object.keys(fehler).length) return { fehler }
const zahlungsarten = ['card', klarna.checked ? 'klarna' : null, sepa.checked ? 'sepa_debit' : null].filter(Boolean)
return {
einstellungen: {
shopname: shopname.value.trim(),
akzent: akzent.value,
anbieter: {
name: anbieterName.value.trim(),
strasse: strasse.value.trim(),
plz: plz.value.trim(),
ort: ort.value.trim(),
land: land.value,
email: email.value.trim(),
telefon: telefon.value.trim(),
ustId: ustId.value.trim(),
},
kleinunternehmer: steuerJa.checked ? true : steuerNein.checked ? false : null,
versand: { aktiv, laender: mitAt ? ['DE', 'AT'] : ['DE'], kostenCent, freiAbCent },
abholung: { aktiv: abholungAktiv.checked, adresse: abholAdresse.value.trim(), hinweis: abholHinweis.value.trim() },
lieferzeit: lieferzeit.value.trim(),
mindestbestellCent,
ruecksendekosten: rueck.value,
zahlungsarten,
},
}
}
/* ---------------------------------------------------------------- Speichern --- */
let laeuft = false
form.addEventListener('submit', async (ev) => {
ev.preventDefault()
if (nurLesen || laeuft) return
laeuft = true
speichern.disabled = true
konfliktPlatz.replaceChildren()
setzeFehler(form, {})
try {
const eingabe = sammle()
if (eingabe.fehler) {
setzeFehler(form, eingabe.fehler)
form.querySelector('[aria-invalid]')?.focus()
return
}
const gespeichert = await aufruf('/api/shop/einstellungen', { method: 'PUT', body: { projectId, einstellungen: eingabe.einstellungen, version } })
if (Number.isInteger(gespeichert?.version)) version = gespeichert.version
if (gespeichert?.einstellungen) fuelle(gespeichert.einstellungen)
if (typeof ctx.zeigeMeldung === 'function') ctx.zeigeMeldung('Die Einstellungen sind gespeichert.', 'ok')
if (typeof ctx.neuLaden === 'function') {
try { await ctx.neuLaden() } catch { /* gespeichert ist gespeichert; der Stand erscheint beim naechsten Laden */ }
}
} catch (err) {
if (err?.status === 409 || err?.code === 'EINSTELLUNG_VERALTET') zeigeKonflikt(err.message)
else zeigeFehler(form, err)
} finally {
laeuft = false
speichern.disabled = false
}
})
if (nurLesen) {
for (const el of form.querySelectorAll('input, select, textarea, button')) el.disabled = true
}
panel.replaceChildren(form)
}
+240
View File
@@ -0,0 +1,240 @@
/* Webshop, Reiter „Rechtliches“ (Plan Block G, Task G6): die Rechtstexte des Haendlers. Er ist Verkaeufer und verantwortlich; WEBklar liefert
Muster und keine Rechtsberatung.
Schnittstelle (server/routes/shop.js): GET /api/shop/rechtstexte?projectId= => { entwurf, bestaetigt: { texte, at, von, aktuell } | null,
muster: { agb, widerrufsbelehrung, widerrufsformular }, versandUndZahlung, gewaehrleistung: { bild, text }, hash, bestaetigungText, vollstaendig,
probleme: [Text] };
PUT /api/shop/rechtstexte { projectId, rechtstexte } speichert den Entwurf (Antwort wie GET, Feldfehler als err.fehler);
POST /api/shop/rechtstexte/bestaetigen { projectId, hash } bestaetigt den gespeicherten Entwurf (409 texte_geaendert, 400 unvollstaendig,
400 nicht_bestaetigbar mit probleme: Platzhalter in eckigen Klammern, Musterkopfzeile, Widerrufsbelehrung ohne Adresse der Widerrufsfunktion;
403 in der Kundenansicht). Der Hash kommt aus der letzten Antwort von GET oder PUT; bestaetigt wird nur, was gespeichert ist: bei ungespeicherten
Aenderungen heisst der Knopf „Erst speichern“. Ersetzen eines vorhandenen Textes durch das Muster fragt vorher in der Seite (kein confirm()).
render(panel, ctx): ctx = { projectId, nurLesen, zeigeMeldung(text, art), neuLaden(), request } (siehe shop.js).
Text kommt nur ueber textContent in die Seite (h), nie ueber innerHTML. */
import { abschnitt, allgemeinerFehler, datumText, feld, frage, h, request, setzeFehler, zeigeFehler } from './shop-dom.js'
export const INTRO = 'Du bist Verkäufer und für deine Rechtstexte verantwortlich. Wir stellen Muster bereit, die du prüfen und anpassen musst – das ist keine Rechtsberatung. Lass deine Texte am besten von einer Kanzlei oder einem Händlerbund prüfen.'
export const NICHT_BESTAETIGT = 'Änderungen noch nicht bestätigt'
export const GEWAEHRLEISTUNG_NOTIZ = 'Dieser Hinweis wird in deinem Shop automatisch vor dem Bestell-Knopf angezeigt. Du musst nichts dafür tun.'
const TEXTE = [
{ key: 'agb', label: 'AGB', zeilen: 14, max: 20000, hinweis: 'Das Muster ist für kleine Shops gedacht. Prüfe es und passe es an dein Angebot an. Höchstens 20.000 Zeichen: Der Text steht vollständig in der Bestätigungs-E-Mail an deine Kunden.' },
{ key: 'widerrufsbelehrung', label: 'Widerrufsbelehrung', zeilen: 14, max: 12000, hinweis: 'Das Muster nutzt deine Angaben aus den Einstellungen (Anbieter, Rücksendekosten). Prüfe sie dort, bevor du das Muster einsetzt. Höchstens 12.000 Zeichen. Die Widerrufsbelehrung muss die Adresse deiner Widerrufsfunktion enthalten; das Muster tut das.' },
{ key: 'widerrufsformular', label: 'Muster-Widerrufsformular', zeilen: 8, max: 3000, hinweis: 'Höchstens 3.000 Zeichen.' },
]
const ADRESSEN = [
{ key: 'impressumUrl', label: 'Adresse deines Impressums', platzhalter: 'https://www.deine-website.de/impressum' },
{ key: 'datenschutzUrl', label: 'Adresse deiner Datenschutzerklärung', platzhalter: 'https://www.deine-website.de/datenschutz' },
]
const text = (wert) => (typeof wert === 'string' ? wert : '')
const normal = (wert) => text(wert).replace(/\r\n?/g, '\n').trim()
/** Nur Adressen auf dem eigenen Server (kein //host, kein Schema) als Bildquelle. */
const eigenerPfad = (pfad) => (typeof pfad === 'string' && /^\/(?!\/)[\w./-]*$/.test(pfad) ? pfad : '')
export async function render(panel, ctx = {}) {
const projectId = ctx.projectId
const aufruf = ctx.request || request
const nurLesen = ctx.nurLesen === true
let daten = await aufruf(`/api/shop/rechtstexte?projectId=${encodeURIComponent(projectId)}`)
/* ----------------------------------------------------------------- Felder --- */
const felder = {}
const platzFragen = {}
const musterKnoepfe = []
const bereiche = TEXTE.map(({ key, label, zeilen, max, hinweis }) => {
const area = h('textarea', { rows: zeilen, maxlength: max, 'data-text': key })
felder[key] = area
platzFragen[key] = h('div', { 'data-frage-platz': key })
const muster = h('button', { type: 'button', class: 'kb-btn-outline', 'data-aktion': `muster-${key}` }, 'Muster einsetzen')
muster.addEventListener('click', () => musterEinsetzen(key, label, muster))
musterKnoepfe.push(muster)
return h('div', null,
feld(key, label, area, hinweis),
nurLesen ? null : h('div', { class: 'sh-aktionen' }, muster),
platzFragen[key])
})
const adressFelder = ADRESSEN.map(({ key, label, platzhalter }) => {
const eingabe = h('input', { type: 'url', inputmode: 'url', autocomplete: 'off', maxlength: 500, placeholder: platzhalter, 'data-text': key })
felder[key] = eingabe
return feld(key, label, eingabe)
})
function fuelle(entwurf) {
const e = entwurf && typeof entwurf === 'object' ? entwurf : {}
for (const key of [...TEXTE, ...ADRESSEN].map((t) => t.key)) felder[key].value = text(e[key])
}
fuelle(daten.entwurf)
/** Ungespeicherte Aenderungen: Eingaben gegen den Entwurf der letzten Antwort. */
const geaendert = () => [...TEXTE, ...ADRESSEN].some(({ key }) => normal(felder[key].value) !== normal(daten.entwurf?.[key]))
/* ----------------------------------------------------------- Muster einsetzen --- */
function musterEinsetzen(key, label, knopf) {
const muster = text(daten.muster?.[key])
const area = felder[key]
const setze = () => {
area.value = muster
platzFragen[key].replaceChildren()
zeichne()
area.focus()
}
// nichts zu verlieren: leer oder schon das Muster
if (normal(area.value) === '' || normal(area.value) === normal(muster)) {
setze()
return
}
const ja = h('button', { type: 'button', class: 'kb-btn-primary', 'data-aktion': 'muster-ja' }, 'Ja, Muster einsetzen')
const nein = h('button', { type: 'button', class: 'kb-btn-outline', 'data-aktion': 'muster-nein' }, 'Abbrechen')
const box = frage(`${label} durch das Muster ersetzen?`,
h('p', { class: 'sh-hinweis' }, 'Dein bisheriger Text wird überschrieben. Du kannst ihn danach nur zurückholen, indem du die Seite neu lädst, bevor du speicherst.'),
h('div', { class: 'sh-aktionen' }, ja, nein))
ja.addEventListener('click', setze)
nein.addEventListener('click', () => { platzFragen[key].replaceChildren(); knopf.focus() })
platzFragen[key].replaceChildren(box)
ja.focus()
}
/* ----------------------------------------------------------------- Lesetexte --- */
const lesetext = (inhalt, art) => h('div', { class: 'sh-begruendung', 'data-lesetext': art }, text(inhalt))
const bild = eigenerPfad(daten.gewaehrleistung?.bild)
const gewaehrleistung = abschnitt('Hinweis zur gesetzlichen Gewährleistung', GEWAEHRLEISTUNG_NOTIZ,
bild ? h('img', { class: 'sh-gewaehrleistung-bild', src: bild, alt: text(daten.gewaehrleistung?.text), loading: 'lazy', 'data-lesetext': 'gewaehrleistung-bild' }) : null,
lesetext(daten.gewaehrleistung?.text, 'gewaehrleistung'))
const versandZahlung = abschnitt('Versand und Zahlung', 'Dieser Text wird aus deinen Einstellungen erzeugt, damit er stimmt. Du kannst ihn hier nicht ändern; ändere die Einstellungen, wenn etwas nicht passt.',
lesetext(daten.versandUndZahlung, 'versand-zahlung'))
/* ------------------------------------------------------------ Status, Bestaetigen --- */
const statusPlatz = h('div', { 'data-status-platz': '' })
const hinweisBest = h('p', { class: 'sh-hinweis', 'data-hinweis': 'bestaetigen' })
const haken = h('input', { type: 'checkbox', 'data-feld': 'bestaetigung' })
const bestaetigen = h('button', { type: 'button', class: 'kb-btn-primary', 'data-aktion': 'bestaetigen' }, 'Texte bestätigen')
const fehlerBest = h('p', { class: 'sh-fehler', 'data-fehler': 'bestaetigung', role: 'alert' })
// Punkte, die das Bestaetigen verhindern (Platzhalter, Musterkopf, fehlende Adresse der Widerrufsfunktion), vom Server
const problemPlatz = h('div', { 'data-probleme-platz': '' })
const bestBlock = h('section', { class: 'kb-panel sh-abschnitt', 'data-block': 'bestaetigen' },
h('h3', null, 'Texte bestätigen'),
problemPlatz,
h('label', { class: 'sh-haken' }, haken, h('span', null, text(daten.bestaetigungText))),
hinweisBest,
fehlerBest,
h('div', { class: 'sh-aktionen' }, bestaetigen))
/** Status, Banner und Bestaetigungsblock nach dem Stand von daten und den Eingaben zeichnen. */
function zeichne() {
const b = daten.bestaetigt
const teile = []
if (b) {
const wann = datumText(b.at)
const wer = text(b.von)
const wo = `${wann ? ` am ${wann} Uhr` : ''}${wer ? ` von ${wer}` : ''}`
if (b.aktuell) {
teile.push(h('p', { class: 'sh-zustand sh-zustand-offen', 'data-status': 'bestaetigt' }, `Bestätigt${wo}.`))
} else {
teile.push(h('div', { class: 'sh-box sh-box-warn', role: 'note', 'data-status': 'unbestaetigt' },
h('span', null, h('strong', null, NICHT_BESTAETIGT), ` – zuletzt bestätigt${wo}. Neue Bestellungen laufen mit dieser Fassung weiter, bis du die geänderten Texte bestätigst.`)))
}
} else {
teile.push(h('p', { class: 'sh-zustand sh-zustand-zu', 'data-status': 'offen' }, 'Noch nicht bestätigt. Ohne bestätigte Texte kann dein Shop nicht öffnen.'))
}
statusPlatz.replaceChildren(...teile)
const schmutzig = geaendert()
const probleme = !schmutzig && Array.isArray(daten.probleme) ? daten.probleme.filter((t) => typeof t === 'string' && t !== '') : []
problemPlatz.replaceChildren(...(probleme.length
? [h('div', { class: 'sh-box sh-box-warn', role: 'alert', 'data-probleme': '' },
h('strong', null, 'Das musst du vor dem Bestätigen noch ändern:'),
h('ul', null, probleme.map((t) => h('li', null, t))))]
: []))
const bereitZumBestaetigen = !schmutzig && daten.vollstaendig === true && probleme.length === 0
// schon bestaetigt und nichts geaendert: nichts zu tun
bestBlock.hidden = Boolean(b?.aktuell) && !schmutzig
bestaetigen.textContent = schmutzig ? 'Erst speichern' : 'Texte bestätigen'
bestaetigen.disabled = !bereitZumBestaetigen || !haken.checked
hinweisBest.textContent = schmutzig
? 'Du hast Änderungen, die noch nicht gespeichert sind. Speichere zuerst den Entwurf, dann kannst du ihn bestätigen.'
: daten.vollstaendig === true
? ''
: 'Zum Bestätigen müssen AGB, Widerrufsbelehrung, Muster-Widerrufsformular sowie die Adressen von Impressum und Datenschutz ausgefüllt und gespeichert sein.'
}
/* ------------------------------------------------------------------- Aufbau --- */
const speichern = h('button', { type: 'submit', class: 'kb-btn-primary', 'data-aktion': 'entwurf-speichern' }, 'Entwurf speichern')
const form = h('form', { class: 'sh-form sh-rechtliches', novalidate: true, 'aria-label': 'Rechtstexte des Webshops' },
abschnitt('Deine Texte', null, ...bereiche),
abschnitt('Impressum und Datenschutz', 'Diese beiden Seiten gehören zu deiner Website. Der Shop verlinkt sie in der Fußzeile und vor der Bestellung.', ...adressFelder),
allgemeinerFehler(),
nurLesen ? null : h('div', { class: 'sh-aktionen' }, speichern))
const intro = h('div', { class: 'sh-box sh-box-info', role: 'note', 'data-hinweis': 'intro' }, INTRO)
panel.replaceChildren(...[intro, statusPlatz, form, versandZahlung, gewaehrleistung, nurLesen ? null : bestBlock].filter(Boolean))
for (const el of Object.values(felder)) el.addEventListener('input', zeichne)
haken.addEventListener('change', zeichne)
/* ---------------------------------------------------------------- Speichern --- */
let laeuft = false
form.addEventListener('submit', async (ev) => {
ev.preventDefault()
if (nurLesen || laeuft) return
laeuft = true
speichern.disabled = true
setzeFehler(form, {})
try {
const rechtstexte = Object.fromEntries([...TEXTE, ...ADRESSEN].map(({ key }) => [key, felder[key].value.trim()]))
daten = await aufruf('/api/shop/rechtstexte', { method: 'PUT', body: { projectId, rechtstexte } })
fuelle(daten.entwurf)
haken.checked = false
zeichne()
if (typeof ctx.zeigeMeldung === 'function') ctx.zeigeMeldung('Der Entwurf ist gespeichert.', 'ok')
} catch (err) {
zeigeFehler(form, err)
} finally {
laeuft = false
speichern.disabled = false
}
})
/* --------------------------------------------------------------- Bestaetigen --- */
let bestaetigtLaeuft = false
bestaetigen.addEventListener('click', async () => {
if (nurLesen || bestaetigtLaeuft || bestaetigen.disabled) return
bestaetigtLaeuft = true
bestaetigen.disabled = true
fehlerBest.textContent = ''
try {
daten = await aufruf('/api/shop/rechtstexte/bestaetigen', { method: 'POST', body: { projectId, hash: daten.hash } })
haken.checked = false
zeichne()
if (typeof ctx.zeigeMeldung === 'function') ctx.zeigeMeldung('Die Texte sind bestätigt.', 'ok')
if (typeof ctx.neuLaden === 'function') {
try { await ctx.neuLaden() } catch { /* bestaetigt ist bestaetigt */ }
}
} catch (err) {
fehlerBest.textContent = err.message
// 400 nicht_bestaetigbar: die Liste des Servers statt des allgemeinen Satzes zeigen
if (err?.code === 'nicht_bestaetigbar' && Array.isArray(err.body?.probleme)) {
daten = { ...daten, probleme: err.body.probleme }
fehlerBest.textContent = ''
}
// 409: die Texte haben sich inzwischen geaendert; der neue Stand wird geholt, ungespeicherte Eingaben gibt es hier nicht (sie sperren den Knopf)
if (err?.status === 409) {
try {
const neu = await aufruf(`/api/shop/rechtstexte?projectId=${encodeURIComponent(projectId)}`)
daten = neu
fuelle(daten.entwurf)
haken.checked = false
} catch { /* die Meldung bleibt stehen */ }
}
zeichne()
} finally {
bestaetigtLaeuft = false
}
})
if (nurLesen) {
for (const el of form.querySelectorAll('input, select, textarea, button')) el.disabled = true
for (const el of panel.querySelectorAll('button')) el.disabled = true
}
zeichne()
}
+249
View File
@@ -0,0 +1,249 @@
/* 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
}
+180
View File
@@ -0,0 +1,180 @@
/* Webshop im Kundenbereich (shop.js): Farben und Abstaende aus den Tokens von kundenbereich.css (.kb-page); nur Klassen mit sh-.
Aufbau wie termine.css: Hinweise und Meldungen, Reiter (5, auf dem Handy waagerecht scrollbar), Karten, Formulare. Die Bestellseite und
die Statuskarte (funktion-bestellen.js) tragen weiter ihre tm-Klassen aus termine.css, die dashboard.html immer mitlaedt. */
#view-webshop .kb-panel { max-width: none; }
#view-webshop .fn-project { margin-bottom: 16px; display: flex; align-items: center; gap: 12px; flex-wrap: wrap; padding: 14px 18px; }
#view-webshop button:focus-visible, #view-webshop input:focus-visible, #view-webshop select:focus-visible, #view-webshop textarea:focus-visible,
#view-webshop summary:focus-visible, #view-webshop a:focus-visible { outline: 3px solid #d4b4ff; outline-offset: 2px; }
.sh-sr { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }
/* -------------------------------------------------- Hinweise und Meldungen --- */
.sh-box { display: flex; align-items: center; justify-content: space-between; gap: 10px 16px; flex-wrap: wrap; margin-bottom: 16px; padding: 12px 16px; border-radius: 12px; font-size: 14px; line-height: 1.5; }
.sh-box-warn { background: rgba(251, 191, 36, 0.12); border: 1px solid rgba(251, 191, 36, 0.45); }
.sh-box-info { background: var(--kb-purple-soft); border: 1px solid var(--kb-purple-line); }
.sh-hinweise:empty { display: none; }
.sh-meldung { margin: 0 0 16px; padding: 12px 16px; border-radius: 12px; font-size: 14px; line-height: 1.5; scroll-margin-top: calc(var(--kb-appbar-h, 76px) + 12px); }
/* leer bleibt die Live-Region im Dokument (sonst liest ein Screenreader die naechste Meldung nicht vor), nimmt aber keinen Platz */
.sh-meldung:empty { margin: 0; padding: 0; border: 0; }
.sh-meldung:focus { outline: none; }
.sh-meldung-ok { background: rgba(74, 222, 128, 0.1); border: 1px solid rgba(74, 222, 128, 0.4); }
.sh-meldung-fehler { background: rgba(248, 113, 113, 0.1); border: 1px solid rgba(248, 113, 113, 0.5); color: #fecaca; }
.sh-info p { margin: 0 0 12px; line-height: 1.55; color: var(--kb-muted); }
.sh-info p:last-child { margin-bottom: 0; }
/* ----------------------------------------------------------------- Reiter --- */
/* fuenf Reiter in einer Zeile; wenn der Platz nicht reicht (Handy), scrollt die Leiste waagerecht statt umzubrechen */
.sh-reiterleiste { display: flex; flex-wrap: nowrap; gap: 6px; margin-bottom: 18px; padding-bottom: 12px; border-bottom: 1px solid var(--kb-border); overflow-x: auto; -webkit-overflow-scrolling: touch; scrollbar-width: thin; }
.sh-reiter {
flex: none; padding: 10px 16px; border-radius: 999px; border: 1px solid transparent; background: transparent; color: var(--kb-muted);
font: inherit; font-size: 14px; font-weight: 700; white-space: nowrap; cursor: pointer;
}
.sh-reiter:hover { color: #fff; background: rgba(255, 255, 255, 0.06); }
.sh-reiter[aria-selected='true'] { color: #fff; background: var(--kb-purple-soft); border-color: var(--kb-purple-line); }
.sh-panel:focus { outline: none; }
/* ------------------------------------------------------------ Karten, Pillen --- */
.sh-abschnitt { margin-bottom: 16px; }
.sh-abschnitt h3 { margin-bottom: 4px; }
.sh-abschnitt h4 { margin: 18px 0 4px; font-size: 14.5px; }
.sh-abschnitt-text { margin: 0 0 14px; line-height: 1.55; }
.sh-pille { display: inline-block; margin-bottom: 8px; padding: 3px 11px; border-radius: 999px; font-size: 12px; font-weight: 700; white-space: nowrap; }
.sh-pille-ok { color: #86efac; background: rgba(74, 222, 128, 0.1); border: 1px solid rgba(74, 222, 128, 0.4); }
.sh-pille-wait { color: #fcd34d; background: rgba(251, 191, 36, 0.1); border: 1px solid rgba(251, 191, 36, 0.4); }
.sh-pille-off { color: var(--kb-muted); background: rgba(255, 255, 255, 0.05); border: 1px solid var(--kb-border-strong); }
.sh-pille-bad { color: #fca5a5; background: rgba(248, 113, 113, 0.1); border: 1px solid rgba(248, 113, 113, 0.45); }
.sh-aktionen { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 16px; }
.sh-fakten { display: grid; gap: 8px; margin: 12px 0 0; }
.sh-fakten > div { display: grid; grid-template-columns: minmax(120px, 190px) minmax(0, 1fr); gap: 4px 14px; }
.sh-fakten dt { font-size: 13px; font-weight: 600; color: var(--kb-muted); }
.sh-fakten dd { margin: 0; overflow-wrap: anywhere; white-space: pre-wrap; }
.sh-begruendung { margin: 0 0 12px; padding: 10px 14px; border-left: 3px solid var(--kb-purple-line); background: rgba(255, 255, 255, 0.04); border-radius: 0 10px 10px 0; line-height: 1.55; overflow-wrap: anywhere; white-space: pre-wrap; }
.sh-zusammenfassung { margin-top: 16px; }
.sh-zusammenfassung h4 { margin: 0 0 4px; font-size: 14.5px; }
/* ------------------------------------------------------------------ Anfrage --- */
.sh-anfrage h3 { margin-bottom: 6px; }
.sh-erklaerung p { margin: 0 0 10px; line-height: 1.55; color: var(--kb-muted); }
.sh-erklaerung ul { margin: 0 0 12px; padding-left: 20px; line-height: 1.6; }
.sh-erklaerung li { margin-bottom: 4px; }
.sh-form { margin-top: 18px; padding-top: 6px; border-top: 1px solid var(--kb-border); }
/* ------------------------------------------------------------------ Formulare --- */
.sh-feld { display: grid; gap: 6px; margin: 12px 0 0; min-width: 0; }
.sh-feld > label, .sh-label { font-size: 13px; font-weight: 600; }
.sh-feld input:not([type=checkbox]):not([type=radio]), .sh-feld select, .sh-feld textarea {
width: 100%; box-sizing: border-box; padding: 11px 12px; border-radius: 12px; font: inherit; font-size: 16px; resize: vertical;
border: 1px solid var(--kb-border-strong); background: rgba(255, 255, 255, 0.05); color: inherit;
}
.sh-feld select option { color: #111; }
.sh-feld [aria-invalid='true'] { border-color: #f87171; }
.sh-feld input:focus, .sh-feld select:focus, .sh-feld textarea:focus { border-color: var(--kb-purple-line); box-shadow: 0 0 0 3px var(--kb-purple-soft); }
.sh-hinweis { margin: 0; font-size: 12.5px; color: var(--kb-muted); }
.sh-fehler { margin: 0; font-size: 13px; color: #fca5a5; }
.sh-fehler:empty { display: none; }
.sh-gruppe { margin: 14px 0 0; padding: 0; border: 0; min-width: 0; }
.sh-gruppe legend { padding: 0; font-size: 13px; font-weight: 600; }
.sh-haken { display: flex; align-items: flex-start; gap: 10px; margin: 8px 0 4px; font-size: 14px; line-height: 1.5; cursor: pointer; }
.sh-haken input { flex: none; width: 18px; height: 18px; margin-top: 2px; accent-color: var(--kb-purple); }
.sh-haken > span { font-weight: 600; }
.sh-haken small { display: block; margin-top: 2px; font-weight: 400; }
.sh-feld-haken { margin-top: 4px; }
.sh-radiogruppe { border: 0; padding: 0; margin: 0 0 8px; min-width: 0; }
.sh-radiogruppe legend { padding: 0; font-weight: 600; font-size: 14px; }
.sh-frage-box { display: grid; gap: 10px; margin: 12px 0; padding: 14px 16px; border-radius: 12px; border: 1px solid var(--kb-purple-line); background: var(--kb-purple-soft); }
.sh-frage-box:focus { outline: none; }
/* ----------------------------------------------------------------- Handy --- */
@media (max-width: 880px), (pointer: coarse) {
#view-webshop button, #view-webshop .kb-btn-outline, #view-webshop .kb-btn-primary { min-height: 44px; }
.sh-haken { align-items: center; min-height: 44px; margin-top: 0; margin-bottom: 0; }
.sh-haken input { margin-top: 0; }
}
@media (max-width: 600px) {
.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; }
/* Reiter Rechtliches (shop-tab-rechtliches.js) */
.sh-gewaehrleistung-bild { display: block; max-width: 100%; height: auto; margin: 0 0 12px; border-radius: 8px; background: #fff; }
.sh-rechtliches textarea { font-family: inherit; line-height: 1.5; }
/* Reiter Bestellungen */
.sh-umschalter { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 16px; }
.sh-umschalter [aria-pressed='true'] { background: var(--kb-purple-soft); border-color: var(--kb-purple-line); color: #fff; }
.sh-werkzeug { display: flex; flex-wrap: wrap; align-items: flex-end; gap: 10px 14px; }
.sh-werkzeug .sh-feld { margin: 0; flex: 1 1 200px; }
.sh-liste { margin: 12px 0 0; padding: 0; list-style: none; display: grid; gap: 8px; }
.sh-zeile {
display: grid; grid-template-columns: minmax(80px, 110px) minmax(110px, 150px) minmax(0, 1fr) minmax(70px, 90px) minmax(80px, 100px) auto; align-items: center; gap: 6px 12px;
width: 100%; padding: 12px 14px; border-radius: 12px; border: 1px solid var(--kb-border-strong); background: rgba(255, 255, 255, 0.04);
color: inherit; font: inherit; font-size: 14px; text-align: left; cursor: pointer;
}
.sh-zeile:hover { background: rgba(255, 255, 255, 0.08); }
.sh-zeile .sh-pille { margin: 0; justify-self: start; }
.sh-zeile-name, .sh-zeile-art { overflow-wrap: anywhere; }
.sh-zeile-summe { font-weight: 700; }
.sh-zeile-warnung { grid-column: 1 / -1; font-size: 12.5px; color: #fcd34d; }
.sh-seiten { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-top: 14px; }
.sh-zurueck { margin-bottom: 14px; }
.sh-positionen, .sh-verlauf { margin: 0 0 12px; padding: 0; list-style: none; display: grid; gap: 6px; }
.sh-positionen li { display: flex; justify-content: space-between; gap: 12px; line-height: 1.5; }
.sh-positionen li > span:first-child { overflow-wrap: anywhere; }
.sh-verlauf li { display: grid; grid-template-columns: minmax(130px, 170px) minmax(0, 1fr); gap: 4px 12px; line-height: 1.5; }
.sh-verlauf-zeit { color: var(--kb-muted); font-size: 13px; }
.sh-aktionen-bereich:empty, .sh-erstatten-frage:empty { display: none; }
.sh-widerruf { padding: 14px 16px; border-radius: 12px; border: 1px solid var(--kb-border-strong); background: rgba(255, 255, 255, 0.04); }
.sh-widerruf .sh-fakten { margin-top: 0; }
@media (max-width: 700px) {
.sh-zeile { grid-template-columns: minmax(0, 1fr) auto; }
.sh-verlauf li { grid-template-columns: minmax(0, 1fr); }
}
/* ----------------------------------------------------- Artikel (shop-tab-artikel.js) --- */
.sh-artikelliste { list-style: none; margin: 12px 0 0; padding: 0; display: grid; gap: 10px; }
.sh-artikel { display: grid; grid-template-columns: 56px minmax(0, 1fr) auto; gap: 8px 14px; align-items: center; padding: 12px; border-radius: 12px; border: 1px solid var(--kb-border); background: rgba(255, 255, 255, 0.03); }
.sh-artikel-bild { width: 56px; height: 56px; border-radius: 10px; object-fit: cover; background: rgba(255, 255, 255, 0.06); display: block; }
.sh-artikel-info { display: grid; gap: 2px; min-width: 0; }
.sh-artikel-name { overflow-wrap: anywhere; }
.sh-artikel-preis { font-variant-numeric: tabular-nums; }
.sh-artikel-bestand { font-size: 13px; color: var(--kb-muted); }
.sh-artikel-aus { color: #fca5a5; }
.sh-artikel-aktionen { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; justify-content: flex-end; }
.sh-schalter { display: inline-flex; align-items: center; gap: 8px; font-size: 14px; font-weight: 600; cursor: pointer; }
.sh-schalter input { width: 18px; height: 18px; accent-color: var(--kb-purple); }
.sh-artikel .sh-frage-box { grid-column: 1 / -1; margin: 4px 0 0; }
.sh-artikel-editor .sh-abschnitt { margin-bottom: 16px; }
.sh-einklappbar > summary { cursor: pointer; padding: 2px 0; }
.sh-einklappbar > summary strong { font-size: 17px; }
.sh-einklappbar[open] > summary { margin-bottom: 6px; }
.sh-untergruppe[hidden], .sh-feld[hidden], .sh-abschnitt[hidden] { display: none; }
.sh-grundpreis { display: grid; grid-template-columns: minmax(0, 2fr) minmax(0, 1fr); gap: 0 14px; }
.sh-wertzeile { display: grid; grid-template-columns: minmax(0, 2fr) minmax(0, 1fr) minmax(0, 1fr) auto; gap: 0 12px; align-items: end; margin-bottom: 4px; }
.sh-wertzeile > button { margin-bottom: 2px; }
.sh-bilder { list-style: none; margin: 10px 0 0; padding: 0; display: flex; flex-wrap: wrap; gap: 12px; }
.sh-bild { display: grid; gap: 6px; justify-items: start; padding: 10px; border-radius: 12px; border: 1px solid var(--kb-border); }
.sh-bild img { width: 96px; height: 96px; object-fit: cover; border-radius: 8px; }
.sh-bild .sh-pille { margin-bottom: 0; }
.sh-bild .sh-aktionen { margin-top: 0; }
@media (max-width: 600px) {
.sh-artikel { grid-template-columns: 56px minmax(0, 1fr); }
.sh-artikel-aktionen { grid-column: 1 / -1; justify-content: flex-start; }
.sh-grundpreis, .sh-wertzeile { grid-template-columns: minmax(0, 1fr); }
}
+362
View File
@@ -0,0 +1,362 @@
/* Webshop im Kundenbereich (ES-Modul, wird von app.js erst beim Oeffnen der Ansicht #webshop geladen). Das ist die Huelle (Muster: termine.js):
initShop(rootEl, { projectId }): Rueckkehr lesen, Projektwahl und je nach Stand des Projekts (GET /api/shop/status) die Ansicht:
- nicht verfuegbar (GET /api/funktionen shop.verfuegbar): Infokarte; keine Projekte: leere Karte;
- state off: keine Anfrage oder zurueckgezogen: das Anfrageformular (shop-anfrage.js); offen: Karte „wird geprueft“ mit „Anfrage
zurueckziehen“; abgelehnt: Begruendung und „Neue Anfrage stellen“; angenommen: die Bestellseite (funktion-bestellen.js, key 'shop');
- state ordered: Statuskarte mit „Zahlung fortsetzen“ (funktion-bestellen.js);
- state setup | active | disabled: die Reiter Uebersicht, Artikel, Bestellungen, Einstellungen, Rechtliches (lazy geladen, siehe REITER);
setup mit Hinweis „Wir bauen deinen Shop gerade ein …“, disabled mit Banner der Abschaltung und nur zum Lesen (ctx.nurLesen).
Rueckkehr: #webshop?bestellt=1&projekt=<id> (nach der Zahlung, „Danke!“) und #webshop?stripe=zurueck|neu&projekt=<id> (vom Onboarding bei
Stripe) werden einmal gelesen und aus der Adresse genommen.
Die Reiter sind Module mit render(panel, ctx) => void | Promise; ctx = { projectId, status, nurLesen, stripeRueckkehr, aktuell(), neuLaden(),
zeigeMeldung(text, art), request } (baueCtx). neuLaden() holt den Stand (GET /api/shop/status), haelt ctx.status aktuell und gibt ihn zurueck;
hat sich dabei der Zustand der Funktion geaendert (z. B. von setup zu active), zeichnet die Huelle die Ansicht neu. Server: server/routes/shop.js.
Helfer ohne Zustand: shop-dom.js. Styles: shop.css. Fremdtext kommt nur ueber textContent in die Seite (nie innerHTML). */
import { fehlerKarte, h, infoKarte, request } from './shop-dom.js'
import { anfrageKarte, anfrageZeilen, renderAnfrageFormular } from './shop-anfrage.js'
import { renderBestellen, statusKarte } from './funktion-bestellen.js'
/** Die Reiter: jeder ein Modul mit render(panel, ctx), erst beim ersten Oeffnen geladen. */
export const REITER = [
{ key: 'uebersicht', label: 'Übersicht', laden: () => import('./shop-tab-uebersicht.js') },
{ key: 'artikel', label: 'Artikel', laden: () => import('./shop-tab-artikel.js') },
{ key: 'bestellungen', label: 'Bestellungen', laden: () => import('./shop-tab-bestellungen.js') },
{ key: 'einstellungen', label: 'Einstellungen', laden: () => import('./shop-tab-einstellungen.js') },
{ key: 'rechtliches', label: 'Rechtliches', laden: () => import('./shop-tab-rechtliches.js') },
]
const HINWEIS_SETUP = 'Wir bauen deinen Shop gerade ein. Du kannst schon Artikel, Stripe und Rechtstexte vorbereiten.'
const HINWEIS_ABGESCHALTET = 'Dein Webshop ist abgeschaltet. Du kannst Bestellungen noch ansehen, bearbeiten, erstatten und exportieren.'
const DANKE_SETUP = 'Danke! Die Zahlung ist bei uns angekommen.'
const NICHT_VERFUEGBAR = 'Der Webshop ist noch nicht verfügbar.'
const SHOP_PREIS = '299 €'
const DOKUMENT_TITEL = [
['leistungsbeschreibung', 'Leistungsbeschreibung'],
['avv', 'Auftragsverarbeitungsvertrag (AVV)'],
['widerruf', 'Widerrufsbelehrung'],
]
// Auswahl bleibt beim Wechsel zwischen Funktionen und Webshop erhalten
const auswahl = { projectId: '', reiter: 'uebersicht' }
/** Gemerkte Auswahl (Website, Reiter) vergessen; fuer Tests, damit eine Ansicht nicht von der vorigen abhaengt. */
export function vergissAuswahl() {
auswahl.projectId = ''
auswahl.reiter = 'uebersicht'
}
let S = null // aktuelle Sitzung der Ansicht; eine alte Antwort darf eine neuere nicht ueberschreiben (S !== s)
const zustandVon = (projekt) => projekt?.features?.shop?.state || 'off'
const text = (wert) => (typeof wert === 'string' ? wert : '')
/* --------------------------------------------------------- Einstieg --- */
/** Rueckkehr aus der Adresse lesen und diese bereinigen: #webshop?bestellt=1&projekt=<id> | #webshop?stripe=zurueck|neu&projekt=<id>. */
function leseRueckkehr() {
const hash = window.location.hash || ''
const leer = { bestellt: false, stripe: '', projekt: '' }
if (!hash.startsWith('#webshop?')) return leer
const query = new URLSearchParams(hash.slice(hash.indexOf('?') + 1))
window.history.replaceState(null, '', `${window.location.pathname}${window.location.search}#webshop`)
const stripe = query.get('stripe')
return { bestellt: query.get('bestellt') === '1', stripe: stripe === 'zurueck' || stripe === 'neu' ? stripe : '', projekt: query.get('projekt') || '' }
}
export function initShop(root, { projectId } = {}) {
if (!root) return Promise.resolve()
return starte(root, projectId)
}
async function starte(root, projectId) {
const rueckkehr = leseRueckkehr()
const wunsch = rueckkehr.projekt || projectId || root.dataset?.wunschProjekt || '' // die Kachel in Funktionen legt ihr Projekt hier ab
if (root.dataset) delete root.dataset.wunschProjekt
if (wunsch) auswahl.projectId = wunsch
const s = { root, projects: [], funktionen: null, projekt: null, status: null, lauf: 0, buehne: null, meldungEl: null, danke: false, stripe: '', neueAnfrage: false }
S = s
try {
await ladeFunktionen(s)
} catch (err) {
if (S === s) root.replaceChildren(fehlerKarte(err.message, () => starte(root, auswahl.projectId)))
return
}
if (S !== s) return
if (!s.funktionen?.shop?.verfuegbar) {
root.replaceChildren(infoKarte(NICHT_VERFUEGBAR, 'Sobald er für dich bereit ist, findest du ihn hier.'))
return
}
if (!s.projects.length) {
root.replaceChildren(h('div', { class: 'kb-panel kb-empty' }, 'Noch keine Website-Projekte zugewiesen.'))
return
}
// Wahl wie bei der Kachel: das gewuenschte Projekt, dann aktiv, in Einrichtung, bestellt, abgeschaltet, das erste
const hat = (zustaende) => s.projects.find((p) => zustaende.includes(zustandVon(p)))
s.projekt = s.projects.find((p) => p.projectId === auswahl.projectId) || hat(['active']) || hat(['setup']) || hat(['ordered']) || hat(['disabled']) || s.projects[0]
auswahl.projectId = s.projekt.projectId
// „Danke!“ und die Stripe-Rueckkehr gelten nur fuer das Projekt, mit dem man zurueckkam
const passt = !rueckkehr.projekt || rueckkehr.projekt === s.projekt.projectId
s.danke = rueckkehr.bestellt && passt
s.stripe = passt ? rueckkehr.stripe : ''
s.meldungEl = h('div', { class: 'sh-meldung', role: 'status', 'aria-live': 'polite', tabindex: '-1' })
s.buehne = h('div', { id: 'shop-buehne' })
root.replaceChildren(...(s.projects.length > 1 ? [projektwahl(s)] : []), s.meldungEl, s.buehne)
await zeigeProjekt(s)
}
function projektwahl(s) {
const select = h('select', { id: 'shop-project' }, s.projects.map((p) => {
const option = h('option', { value: p.projectId }, p.projectName)
option.selected = p.projectId === s.projekt.projectId
return option
}))
select.addEventListener('change', () => {
auswahl.projectId = select.value
s.projekt = s.projects.find((p) => p.projectId === select.value)
s.danke = false // der Dank gilt dem Projekt, mit dem man von Stripe zurueckkam
s.stripe = ''
s.neueAnfrage = false
zeigeMeldung(s, '')
zeigeProjekt(s)
})
return h('div', { class: 'fn-project kb-panel' }, h('label', { for: 'shop-project' }, 'Website'), select)
}
/** Liste der Funktionen holen (GET /api/funktionen): Projekte, features.shop, legal.shop, ustHinweis, shop.verfuegbar. Wirft bei einem Fehler. */
async function ladeFunktionen(s) {
const antwort = await request('/api/funktionen')
s.funktionen = antwort
s.projects = Array.isArray(antwort.projects) ? antwort.projects : []
if (s.projekt) s.projekt = s.projects.find((p) => p.projectId === s.projekt.projectId) || s.projekt
}
const ladeStatus = (projectId) => request(`/api/shop/status?projectId=${encodeURIComponent(projectId)}`)
/** Meldung in der Live-Region ueber der Ansicht (art: ok | fehler); leerer Text leert sie. */
function zeigeMeldung(s, meldung, art = 'ok') {
const el = s.meldungEl
if (!el) return
el.textContent = meldung || ''
el.className = `sh-meldung sh-meldung-${art}`
el.setAttribute('role', art === 'fehler' ? 'alert' : 'status')
el.setAttribute('aria-live', art === 'fehler' ? 'assertive' : 'polite')
if (!meldung) return
if (art === 'fehler') el.focus({ preventScroll: true })
el.scrollIntoView({ block: 'nearest' })
}
/**
* Stand des Projekts holen und die Ansicht zeichnen. Jeder Aufruf zaehlt s.lauf hoch: die Antwort einer frueheren Website (langsam, dann zur
* anderen gewechselt) wird verworfen und erscheint nie unter der neuen.
*/
async function zeigeProjekt(s, optionen = {}) {
const projekt = s.projekt
const lauf = ++s.lauf
const aktuell = () => S === s && s.lauf === lauf
s.buehne.replaceChildren(h('p', { class: 'kb-empty' }, 'Lade…'))
let status
try {
status = await ladeStatus(projekt.projectId)
} catch (err) {
if (aktuell()) s.buehne.replaceChildren(fehlerKarte(err.message, () => zeigeProjekt(s)))
return
}
if (!aktuell()) return
await zeichneAnsicht(s, { status, aktuell, ...optionen })
}
/**
* Liste und Stand frisch holen und die Ansicht neu zeichnen (nach einer Aktion der Karten). Scheitert das Laden, wirft die Funktion und die
* Karte bleibt mit ihrer Meldung stehen.
*/
async function ladeNeu(s, optionen = {}) {
const lauf = s.lauf
await ladeFunktionen(s)
const status = await ladeStatus(s.projekt.projectId)
if (S !== s || s.lauf !== lauf) return // inzwischen hat ein Wechsel der Website die Ansicht ersetzt
const nr = ++s.lauf
await zeichneAnsicht(s, { status, aktuell: () => S === s && s.lauf === nr, ...optionen })
}
/** Die Ansicht zum Zustand aus dem frisch geladenen Stand (status.state). */
async function zeichneAnsicht(s, { status, aktuell, bestellformular = false, meldung = null }) {
s.status = status
if (!status.verfuegbar) {
s.buehne.replaceChildren(infoKarte(NICHT_VERFUEGBAR, 'Sobald er für dich bereit ist, findest du ihn hier.'))
return
}
const zustand = status.state || zustandVon(s.projekt)
if (zustand === 'off') zeigeAnfrage(s, { status, aktuell })
else if (zustand === 'ordered') zeigeBestellung(s, { status, aktuell, bestellformular })
else if (zustand === 'setup' || zustand === 'active' || zustand === 'disabled') zeigeReiter(s, { status, aktuell, zustand })
else s.buehne.replaceChildren(infoKarte('Webshop', 'Der Stand deines Webshops ist gerade nicht lesbar. Bitte lade die Seite neu.'))
if (meldung) zeigeMeldung(s, meldung.text, meldung.art)
}
/* ----------------------------------------------- Zustand off: Anfrage --- */
function zeigeAnfrage(s, { status, aktuell }) {
const projectId = s.projekt.projectId
const ansicht = status.anfrage
const f = s.funktionen?.features?.shop
if (ansicht?.status === 'angenommen') {
zeigeBestellseite(s, { status, aktuell })
return
}
if (ansicht?.status === 'offen') {
s.buehne.replaceChildren(anfrageKarte(ansicht, { projectId, neuLaden: () => ladeNeu(s) }))
return
}
if (ansicht?.status === 'abgelehnt' && !s.neueAnfrage) {
s.buehne.replaceChildren(anfrageKarte(ansicht, { projectId, neu: () => { s.neueAnfrage = true; zeigeAnfrage(s, { status, aktuell }) } }))
return
}
// keine Anfrage, zurueckgezogen oder „Neue Anfrage stellen“: das Formular, mit den frueheren Angaben vorausgefuellt
const wurzel = h('div', { class: 'sh-wurzel' })
s.buehne.replaceChildren(wurzel)
renderAnfrageFormular(wurzel, {
projectId,
vorgabe: ansicht?.anfrage || null,
preis: text(f?.priceLabel) || SHOP_PREIS,
ustHinweis: text(s.funktionen?.ustHinweis),
nachSenden: async () => { s.neueAnfrage = false; await ladeNeu(s) },
})
}
/* ----------------------------------------------- Bestellseite, Statuskarte --- */
/** Die Bestellung (Daten fuer renderBestellen) aus GET /api/funktionen: features.shop (Preis, Zustimmungen, Unterlagen), legal.shop, ustHinweis. */
export function bestellungVon(funktionen) {
const f = funktionen?.features?.shop
if (!f || !f.consent) return null
const dokumente = DOKUMENT_TITEL
.filter(([id]) => typeof f[id]?.url === 'string' && f[id].url !== '')
.map(([id, titel]) => ({ id, titel, version: f[id].version, url: f[id].url }))
const belehrung = funktionen?.legal?.shop?.belehrung
return {
preis: text(f.priceLabel) || SHOP_PREIS,
preisHinweis: text(funktionen.ustHinweis),
einrichtung: text(f.setupEstimate),
consent: f.consent,
dokumente,
belehrung: typeof belehrung === 'string' ? belehrung : null,
}
}
/** Daten fuer renderBestellen: nur die Bestellung, keine offenen Punkte (die Anfrage ist angenommen). Ist die Anfrage nicht (mehr) angenommen, gibt es keine Bestellung. */
function bestelldaten(s, status) {
const bestellbar = status?.state === 'off' ? status.anfrage?.status === 'angenommen' : status?.state === 'ordered'
return { bestellung: bestellbar ? bestellungVon(s.funktionen) : null, offen: [], kundenansicht: false, anfrage: status?.anfrage?.anfrage || null }
}
/** Zusammenfassung der Anfrage fuer die Bestellseite: eine Gruppe ohne „Ändern“ (zumSchritt fehlt). */
const zusammenfassungVon = (anfrage) => () => (anfrage
? [{ schritt: 'anfrage', titel: 'Deine Anfrage', zeilen: anfrageZeilen(anfrage).map(({ name, wert }) => `${name}: ${wert}`) }]
: [])
function zeigeBestellseite(s, { status, aktuell }) {
const wurzel = h('div', { class: 'sh-wurzel' })
s.buehne.replaceChildren(wurzel)
const ctx = {
projectId: s.projekt.projectId,
aktuell,
// nach einem 409 der Bestellung: Stand neu holen; die Seite zeichnet sich mit den neuen Daten (ohne bestellung: mit dem Hinweis zum Neuladen)
neuLaden: async () => {
await ladeFunktionen(s)
const neu = await ladeStatus(s.projekt.projectId)
if (aktuell()) s.status = neu
return bestelldaten(s, neu)
},
}
const daten = bestelldaten(s, status)
renderBestellen(wurzel, { ctx, daten, key: 'shop', zusammenfassung: zusammenfassungVon(daten.anfrage) })
}
/** Zustand ordered: Statuskarte „Zahlung offen“ mit „Zahlung fortsetzen“; scheitert das, zeichnet weiter() die Ansicht neu (Bestellseite bei fehlender Zustimmung). */
function zeigeBestellung(s, { status, aktuell, bestellformular }) {
if (bestellformular) {
zeigeBestellseite(s, { status, aktuell })
return
}
const f = s.funktionen?.features?.shop
const eintrag = { ...s.projekt.features?.shop, state: 'ordered' }
s.buehne.replaceChildren(statusKarte({
projekt: s.projekt,
bestellung: eintrag,
einrichtung: text(f?.setupEstimate),
danke: s.danke,
kundenansicht: false,
key: 'shop',
name: 'Webshop',
}, {
weiter: async ({ bestellen, meldung }) => {
await ladeNeu(s, { bestellformular: bestellen, meldung: { text: meldung, art: 'fehler' } })
},
}))
}
/* ----------------------------------------------- Zustand setup, active, disabled: Reiter --- */
function zeigeReiter(s, { status, aktuell, zustand }) {
const hinweis = zustand === 'setup' ? HINWEIS_SETUP : zustand === 'disabled' ? HINWEIS_ABGESCHALTET : ''
const hinweisBox = h('div', { class: 'sh-hinweise' }, hinweis ? h('div', { class: `sh-box ${zustand === 'disabled' ? 'sh-box-warn' : 'sh-box-info'}`, role: 'note', 'data-hinweis': zustand }, hinweis) : null)
// neuLaden: Stand holen; hat sich der Zustand geaendert, wird die Ansicht neu gezeichnet (sonst bleibt der Reiter, wie er ist)
const neuLaden = async () => {
const neu = await ladeStatus(s.projekt.projectId)
if (!aktuell()) return neu
s.status = neu
ctx.status = neu
if ((neu.state || 'off') !== zustand) await zeichneAnsicht(s, { status: neu, aktuell })
return neu
}
const ctx = {
projectId: s.projekt.projectId,
status,
nurLesen: zustand === 'disabled',
abwickeln: true, // Bestellungen, Erstattungen und Widerrufe gehen auch bei abgeschaltetem Shop (Pflichten gegenueber Kaeufern)
stripeRueckkehr: s.stripe,
aktuell,
request,
// eine Meldung, die erst nach dem Wechsel der Website oder der Ansicht eintrifft, gehoert nicht mehr auf diese Seite
zeigeMeldung: (meldung, art) => { if (aktuell()) zeigeMeldung(s, meldung, art) },
neuLaden,
}
const panel = h('div', { class: 'sh-panel', role: 'tabpanel', id: 'sh-panel', tabindex: '0' })
let aktiv = null // Schluessel des gezeigten Reiters
let zaehler = 0 // nur der zuletzt gewaehlte Reiter zeichnet in das Panel
const knoepfe = REITER.map((r) => h('button', {
type: 'button', role: 'tab', class: 'sh-reiter', id: `sh-tab-${r.key}`, 'aria-controls': 'sh-panel', 'data-reiter': r.key,
onclick: () => waehleReiter(r.key),
}, r.label))
const leiste = h('div', { class: 'sh-reiterleiste', role: 'tablist', 'aria-label': 'Bereiche des Webshops' }, knoepfe)
leiste.addEventListener('keydown', (ev) => {
const i = REITER.findIndex((r) => r.key === aktiv)
const ziel = { ArrowRight: (i + 1) % REITER.length, ArrowLeft: (i + REITER.length - 1) % REITER.length, Home: 0, End: REITER.length - 1 }[ev.key]
if (ziel === undefined) return
ev.preventDefault()
waehleReiter(REITER[ziel].key)
knoepfe[ziel].focus()
})
function waehleReiter(key, { merken = true } = {}) {
const reiter = REITER.find((r) => r.key === key) || REITER[0]
if (merken) auswahl.reiter = reiter.key
aktiv = reiter.key
const nr = ++zaehler
for (const k of knoepfe) {
const an = k.dataset.reiter === reiter.key
k.setAttribute('aria-selected', String(an))
k.tabIndex = an ? 0 : -1
}
panel.setAttribute('aria-labelledby', `sh-tab-${reiter.key}`)
zeigeMeldung(s, '')
panel.replaceChildren()
reiter.laden()
.then((modul) => (nr === zaehler && aktuell() ? modul.render(panel, ctx) : undefined))
.catch((err) => { if (nr === zaehler && aktuell()) panel.replaceChildren(fehlerKarte(err.message, () => waehleReiter(reiter.key))) })
}
s.buehne.replaceChildren(hinweisBox, leiste, panel)
// vom Onboarding bei Stripe zurueck: die Uebersicht zeigt den Stand der Stripe-Karte
waehleReiter(s.stripe && zustand !== 'disabled' ? 'uebersicht' : auswahl.reiter, { merken: false })
if (s.danke && zustand === 'setup') zeigeMeldung(s, DANKE_SETUP)
s.danke = false
s.stripe = ''
}
+1
View File
@@ -655,6 +655,7 @@ export function zusammenfassung(einstellung, fragebogen, branchen) {
zeile('branche', istText(branche?.name) && `Branche: ${branche.name}`)
zeile('branche', istText(betrieb.name) && `Betrieb: ${betrieb.name}`)
zeile('branche', istText(betrieb.adresse) && `Anschrift: ${betrieb.adresse}`)
zeile('branche', typeof betrieb.kleinunternehmer === 'boolean' && `Kleinunternehmer: ${betrieb.kleinunternehmer ? 'ja' : 'nein'}`)
// 2. Wer wird gebucht: Personen und Plaetze getrennt, in der Reihenfolge ihres ersten Auftretens
const nachTyp = new Map()
+14 -8
View File
@@ -176,13 +176,16 @@ const neueMeldung = () => h('div', { class: 'tm-meldung', role: 'status', 'aria-
/* ----------------------------------------------- Zustand off: Hinweis, Fragebogen --- */
/**
* Ohne laufendes Wartungs-Abo gibt es keinen Fragebogen, nur den Hinweis wie in der Kachel. Der Text kommt ausschliesslich vom Server
* (GET /api/funktionen, termine.hinweis = aboMeldung in server/services/featureOrder.js), nie aus einer Kopie im Browser; bei
* kein_abo kommt der Link zu den Vertraegen dazu (Quelle: vertraege-link.js). Fehlt der Text, weil sich das Abo seit dem Laden der Liste
* geaendert hat, wird die Liste noch einmal geholt.
* Ohne laufendes Wartungs-Abo gibt es keinen Fragebogen, nur den Hinweis. Der Text kommt ausschliesslich vom Server, nie aus einer Kopie im
* Browser: aboHinweis aus GET /api/termine/einstellungen (derselbe Aufruf, der den Zustand liefert, also auch dann gefuellt, wenn das Abo nicht
* lesbar war oder sich seit dem Laden der Liste geaendert hat), ersatzweise termine.hinweis aus GET /api/funktionen (beide: aboMeldung in
* server/services/featureOrder.js). Fehlt der Text auch dort, wird die Liste noch einmal geholt. Bei kein_abo kommt der Link zu den
* Vertraegen dazu (Quelle: vertraege-link.js). „Neu laden“ holt Daten und Ansicht neu, damit der Kunde nach einer Aenderung des Abos nicht
* an der Karte haengen bleibt.
*/
async function zeigeAboHinweis(s, { aktuell }) {
if (typeof s.termine?.hinweis !== 'string' || s.termine.hinweis === '') {
const textVon = (wert) => (typeof wert === 'string' ? wert : '')
if (textVon(s.daten?.aboHinweis) === '' && textVon(s.termine?.hinweis) === '') {
try {
await ladeFunktionen(s)
} catch {
@@ -190,13 +193,16 @@ async function zeigeAboHinweis(s, { aktuell }) {
}
if (!aktuell()) return
}
const hinweis = typeof s.termine?.hinweis === 'string' ? s.termine.hinweis : ''
const link = s.termine?.bestellbar === false && s.termine.grund === 'kein_abo'
const hinweis = textVon(s.daten?.aboHinweis) || textVon(s.termine?.hinweis)
const keinAbo = s.daten?.abo?.grund === 'kein_abo' || (s.termine?.bestellbar === false && s.termine.grund === 'kein_abo')
const link = keinAbo
? h('button', { type: 'button', class: ZU_VERTRAEGE.klasse, 'data-view': ZU_VERTRAEGE.view }, ZU_VERTRAEGE.text)
: null
s.buehne.replaceChildren(h('div', { class: 'kb-panel tm-info', 'data-hinweis': 'abo' },
h('h3', null, 'Terminplaner einrichten'),
hinweis || link ? h('p', null, hinweis, hinweis && link ? ' ' : null, link) : null))
hinweis || link ? h('p', null, hinweis, hinweis && link ? ' ' : null, link) : null,
h('div', { class: 'tm-aktionen' },
h('button', { type: 'button', class: 'kb-btn-outline', 'data-aktion': 'neu-laden', onclick: () => zeigeProjekt(s) }, 'Neu laden'))))
}
/** Der Fragebogen mit der Bestellseite danach (termine-fragebogen.js); die Meldung ueber ihm ist s.meldungEl (ctx.meldung). */
+2 -2
View File
@@ -1,5 +1,5 @@
/* Der Link „Zu Verträge“ in den Hinweisen ohne Wartungs-Abo (Grund kein_abo): die einzige Quelle für Text, Ziel und Klasse. Gelesen von
funktionen.js (Kachel Analyse, Analyse-Seite, Kachel Terminplaner; baut daraus HTML) und von termine.js (Hinweis in der Ansicht #termine;
baut daraus ein Element). Reine Daten ohne Import: beide laden das Modul relativ, funktionen.js behält seine zwei absoluten Importe.
funktionen.js (Kachel Analyse, Kachel Terminplaner; baut daraus HTML), analyse.js (Analyse-Seite; baut daraus HTML) und termine.js (Hinweis
in der Ansicht #termine; baut daraus ein Element). Reine Daten ohne Import: alle laden das Modul relativ.
Die Hinweistexte selbst stehen nicht hier: sie kommen vom Server (aboMeldung in server/services/featureOrder.js). */
export const ZU_VERTRAEGE = Object.freeze({ text: 'Zu Verträge', view: 'vertraege', klasse: 'kb-link' })
@@ -0,0 +1,78 @@
<!DOCTYPE html>
<html lang="de">
<!-- ENTWURF, Freigabe Nutzer ausstehend -->
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Leistungsbeschreibung Funktionen – News-Pinnwand – WEBklar</title>
<style>
:root { color-scheme: light; }
* { box-sizing: border-box; }
body { margin: 0; background: #f7f6f3; color: #1d1d1f; font: 16px/1.6 system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif; }
main { max-width: 780px; margin: 0 auto; padding: 44px 20px 80px; }
.brand { font-weight: 800; letter-spacing: 0.12em; font-size: 13px; color: #6d1fd1; }
h1 { font-size: 30px; line-height: 1.2; margin: 6px 0 6px; }
h2 { font-size: 20px; margin: 36px 0 10px; padding-top: 14px; border-top: 1px solid #e3e0da; }
p, li { margin: 6px 0; }
ul { padding-left: 24px; }
.meta { color: #6b6b70; font-size: 14px; }
.box { background: #fff; border: 1px solid #e3e0da; border-radius: 14px; padding: 16px 20px; margin: 18px 0; }
table { border-collapse: collapse; width: 100%; font-size: 15px; }
td { padding: 6px 10px 6px 0; border-bottom: 1px solid #eee; vertical-align: top; text-align: left; }
td:first-child { font-weight: 600; width: 30%; }
a { color: #6d1fd1; }
nav.top { font-size: 14px; margin-bottom: 22px; }
@media print {
@page { size: A4; margin: 18mm 17mm 18mm; }
body { background: #fff; font-size: 10pt; line-height: 1.45; }
main { max-width: none; padding: 0; }
nav.top { display: none; }
h1 { font-size: 20pt; }
h2 { font-size: 13pt; margin-top: 16pt; break-after: avoid; }
a { color: inherit; text-decoration: none; }
.box, tr { break-inside: avoid; }
}
</style>
</head>
<body>
<main>
<nav class="top"><a href="/dashboard.html">← Zurück zum Kundenportal</a> · <a href="/abo-bedingungen.html">Abo-Bedingungen</a> · <a href="/datenschutz.html#news">Datenschutz</a></nav>
<div class="brand">WEBKLAR</div>
<h1>Leistungsbeschreibung: Funktionen – News-Pinnwand</h1>
<p class="meta">Stand 30.09.2026</p>
<div class="box">
<strong>Das Wichtigste auf einen Blick</strong>
<table>
<tr><td>Leistung</td><td>Einbau der News-Pinnwand in Ihre Website durch WEBklar; Pflege der Beiträge durch Sie im Kundenportal</td></tr>
<tr><td>Preis</td><td>30,00 € einmalig, inklusive Umsatzsteuer, keine laufenden Kosten</td></tr>
<tr><td>Dauer des Einbaus</td><td>meist innerhalb von 1–2 Wochen (keine verbindliche Zusage)</td></tr>
<tr><td>Widerruf</td><td>Verbraucher können innerhalb von 14 Tagen widerrufen; das Recht kann nach § 356 Abs. 4 BGB erlöschen (Abschnitt 6)</td></tr>
</table>
</div>
<h2 id="leistung">1. Leistung</h2>
<p>Wir bauen die News-Pinnwand in die Website ein, die Sie für das gewählte Projekt bei uns im Kundenportal führen. Die Pinnwand zeigt Ihre Beiträge als Zettel mit Titel, Text und Foto; pro Seite erscheinen sechs Beiträge, die neuesten zuerst. Die Beiträge schreiben, ändern und löschen Sie selbst im Kundenportal im Bereich „Funktionen“. Sie wählen je Beitrag Zettelfarbe und Anzeigedauer; danach verschwindet der Beitrag von Ihrer Website. Hochgeladene Fotos werden verkleinert, alle Metadaten einschließlich Aufnahmeort (GPS) werden entfernt.</p>
<p>Änderungen an der Website, die über den Einbau der Pinnwand hinausgehen, sind nicht Teil dieser Leistung.</p>
<h2 id="preis">2. Preis</h2>
<p>Der Einbau kostet 30,00 € einmalig, inklusive Umsatzsteuer. Die Nutzung der Pinnwand danach kostet nichts; es entstehen keine laufenden Kosten und kein Abo. Sie bezahlen bei der Bestellung im Kundenportal über Stripe und erhalten eine Rechnung.</p>
<h2 id="einbau">3. Einbau und Freischaltung</h2>
<p>Mit Ihrer Bestellung legen wir einen Auftrag für den Einbau an. Der Einbau ist erfahrungsgemäß meist innerhalb von 1–2 Wochen abgeschlossen. Das ist eine Einschätzung und keine verbindliche Zusage eines Termins. Nach Abschluss des Einbaus schalten wir die Funktion für Sie frei; ab dann erscheinen Ihre Beiträge auf der Website.</p>
<h2 id="ende">4. Ende der Zusammenarbeit</h2>
<p>Auf Ihren Wunsch schalten wir die Pinnwand ab; das tun wir auch, wenn die Zusammenarbeit endet und wir die Funktion bei Ihnen abschalten. Ihre Beiträge und Fotos werden gelöscht, wenn Sie sie selbst löschen, andernfalls nach den Fristen der Datenschutzerklärung (<a href="/datenschutz.html#news">Abschnitt 6a</a>, dort Abschnitt 7).</p>
<h2 id="inhalte">5. Ihre Inhalte</h2>
<p>Sie sind für die Inhalte Ihrer Beiträge und Fotos verantwortlich. Sie versichern, dass Sie die nötigen Rechte an Texten und Fotos haben und dass die Inhalte keine Rechte Dritter verletzen, etwa Urheberrechte, Persönlichkeitsrechte oder das Recht am eigenen Bild abgebildeter Personen.</p>
<h2 id="widerruf">6. Widerrufsrecht</h2>
<p>Sind Sie Verbraucher, können Sie diesen Vertrag innerhalb von 14 Tagen ohne Angabe von Gründen widerrufen. Die Frist beginnt mit dem Vertragsschluss. Zum Widerruf genügt eine eindeutige Erklärung an uns, zum Beispiel über <a href="/widerrufen.html">project.webklar.com/widerrufen.html</a>.</p>
<p>Verlangen Sie ausdrücklich, dass wir vor Ablauf der Widerrufsfrist mit dem Einbau beginnen, müssen Sie uns bei einem Widerruf einen angemessenen Betrag für die bis dahin erbrachte Leistung zahlen. Das Widerrufsrecht erlischt nach § 356 Abs. 4 BGB, wenn wir die Leistung vollständig erbracht haben und Sie vorher ausdrücklich zugestimmt haben, dass wir vor Ablauf der Widerrufsfrist beginnen, und Ihre Kenntnis bestätigt haben, dass Sie Ihr Widerrufsrecht bei vollständiger Vertragserfüllung verlieren.</p>
<p class="meta">Anbieter: WEBklar GbR, Am Schwimmbad 10, 67722 Winnweiler. Es gelten ergänzend die <a href="/abo-bedingungen.html">Vertragsbedingungen</a> und die <a href="/datenschutz.html">Datenschutzerklärung</a>.</p>
</main>
</body>
</html>
@@ -41,6 +41,7 @@
<div class="brand">WEBKLAR</div>
<h1>Leistungsbeschreibung: Funktionen – News-Pinnwand</h1>
<p class="meta">Stand 05.10.2026</p>
<p class="meta">Frühere Fassung vom 30.09.2026: <a href="/vertragsunterlagen/funktionen-leistungsbeschreibung-2026-09-30.html">Leistungsbeschreibung (Stand 30.09.2026)</a></p>
<div class="box">
<strong>Das Wichtigste auf einen Blick</strong>
+1
View File
@@ -22,6 +22,7 @@
<label class="radio"><input type="radio" name="order" value="zusatzspeicher" data-text="E-Mail-Zusatzspeicher"> E-Mail-Zusatzspeicher</label>
<label class="radio"><input type="radio" name="order" value="funktion" data-text="Funktion (z. B. News-Pinnwand)"> Funktion (z. B. News-Pinnwand)</label>
<label class="radio"><input type="radio" name="order" value="termine" data-text="Terminplaner (Einrichtung)"> Terminplaner (Einrichtung)</label>
<label class="radio"><input type="radio" name="order" value="shop" data-text="Webshop (Einrichtung)"> Webshop (Einrichtung)</label>
<label class="radio"><input type="radio" name="order" value="zeiten" data-text="Öffnungszeiten live"> Öffnungszeiten live</label>
</fieldset>