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:
commit
e87140e5ba
245 files changed
+38799
-1050
No files matched your search
@@ -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 ? '< 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
@@ -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 }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''')
|
||||
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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''')
|
||||
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>
|
||||
|
||||
|
||||
Binary file not shown.
@@ -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>
|
||||
Binary file not shown.
@@ -0,0 +1 @@
|
||||
081f98f8a0be20d003863c25417e9896ace5e6fbbacaf6ec3c481f32edee1018
|
||||
@@ -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')))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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 = ''
|
||||
}
|
||||
@@ -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
@@ -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). */
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in new issue
Block a user