diff --git a/analytics/src/stats.js b/analytics/src/stats.js index e2033a9..f065080 100644 --- a/analytics/src/stats.js +++ b/analytics/src/stats.js @@ -1,69 +1,137 @@ // Statistik für das Portal: Vergangenheit aus `daily`, noch nicht verdichtete Tage live aus den Rohdaten. import { dayOf, aggregateRows } from './jobs.js'; -const DAY_MS = 86400 * 1000; const TOP = 20; -// Liste der Kalendertage (YYYY-MM-DD), oldest first, endend am Berlin-Tag von `now`. -function dayList(now, range) { - const [y, m, d] = dayOf(now).split('-').map(Number); +// Kalendertag (YYYY-MM-DD) um `n` Tage verschoben, rein kalendarisch (UTC-Datumsrechnung, keine Zeitumstellung). +function shiftDay(day, n) { + const [y, m, d] = day.split('-').map(Number); + return new Date(Date.UTC(y, m - 1, d + n)).toISOString().slice(0, 10); +} + +// Liste der Kalendertage (YYYY-MM-DD), oldest first, endend am Tag `endDay`. +function dayList(endDay, range) { const days = []; - for (let i = range - 1; i >= 0; i--) days.push(new Date(Date.UTC(y, m - 1, d - i)).toISOString().slice(0, 10)); + for (let i = range - 1; i >= 0; i--) days.push(shiftDay(endDay, -i)); return days; } const top = (map) => [...map].map(([key, count]) => ({ key, count })).filter((e) => e.count > 0) .sort((a, b) => b.count - a.count || (a.key < b.key ? -1 : 1)).slice(0, TOP); -export function computeStats(db, site, range, now = Date.now()) { - const days = dayList(now, range); +// Sammelt die Zähler für eine zusammenhängende Tagesliste: verdichtete Tage aus `daily`, +// Tage ohne agg_state (heute, ggf. gestern vor 02:30) live aus den Rohdaten. Kein Tag zählt doppelt. +function collect(db, site, days, now) { const first = days[0]; + const last = days[days.length - 1]; const inRange = new Set(days); - const pvByDay = new Map(days.map((d) => [d, 0])); + const perDay = new Map(days.map((d) => [d, { pv: 0, visitors: 0, contacts: 0, en: 0, sec: 0, scroll: 0 }])); const dims = {}; - const bump = (dim, key, n) => { + const apply = (day, dim, key, n) => { + const t = perDay.get(day); + if (dim === 'pv_total') t.pv += n; + else if (dim === 'visitors') t.visitors += n; + else if (dim === 'ct_kind') t.contacts += n; + else if (dim === 'en_count') t.en += n; + else if (dim === 'en_sec_sum') t.sec += n; + else if (dim === 'scroll_sum') t.scroll += n; if (!dims[dim]) dims[dim] = new Map(); dims[dim].set(key, (dims[dim].get(key) || 0) + n); }; - const apply = (day, dim, key, n) => { - if (dim === 'pv_total') pvByDay.set(day, pvByDay.get(day) + n); - bump(dim, key, n); - }; - const done = new Set(db.prepare('SELECT day FROM agg_state WHERE site = ? AND day >= ?').all(site, first).map((r) => r.day)); - for (const r of db.prepare('SELECT day, dim, key, count FROM daily WHERE site = ? AND day >= ?').all(site, first)) { + const done = new Set(db.prepare('SELECT day FROM agg_state WHERE site = ? AND day >= ? AND day <= ?').all(site, first, last).map((r) => r.day)); + for (const r of db.prepare('SELECT day, dim, key, count FROM daily WHERE site = ? AND day >= ? AND day <= ?').all(site, first, last)) { if (inRange.has(r.day)) apply(r.day, r.dim, r.key, r.count); } - // Tage ohne agg_state (heute, ggf. gestern vor 02:30) live aus den Rohdaten + // Ein Berliner Tag beginnt 22:00/23:00 UTC des Vortags: zwei Tage Rand reichen auf beiden Seiten, danach filtert dayOf + const [fy, fm, fd] = first.split('-').map(Number); + const [ly, lm, ld] = last.split('-').map(Number); + const lo = Date.UTC(fy, fm - 1, fd - 2); + const hi = Math.min(now, Date.UTC(ly, lm - 1, ld + 2)); const live = new Map(); - const from = now - (range + 1) * DAY_MS; for (const r of db.prepare('SELECT ts, type, path, vp, ref, country, device, visitor, sec, scroll, kind FROM events_raw WHERE site = ? AND ts >= ? AND ts <= ? ORDER BY ts') - .all(site, from, now)) { + .all(site, lo, hi)) { const d = dayOf(r.ts); if (!inRange.has(d) || done.has(d)) continue; if (!live.has(d)) live.set(d, []); live.get(d).push(r); } for (const [day, rows] of live) for (const [dim, key, n] of aggregateRows(rows)) apply(day, dim, key, n); + return { days, perDay, dims }; +} +const avg = (sum, n) => (n ? Math.round(sum / n) : null); + +// Formt gesammelte Zähler zu banner + totals + series (gleiche Form für aktuellen Zeitraum und Vorzeitraum). +function shape({ days, perDay, dims }) { const sum = (dim) => [...(dims[dim] || new Map()).values()].reduce((a, b) => a + b, 0); const banner = { shown: sum('bn_shown'), accept: sum('bn_accept'), reject: sum('bn_reject') }; const decided = banner.accept + banner.reject; - const contacts = top(dims.ct_kind || new Map()); + const en = sum('en_count'); return { + banner, totals: { pv: sum('pv_total'), // Summe der Tages-Besucher (kein tagesübergreifender Abgleich möglich: Salz wechselt täglich) visitors: sum('visitors'), contacts: sum('ct_kind'), consentRate: decided ? banner.accept / decided : 0, + engaged: en, + avgSec: avg(sum('en_sec_sum'), en), + avgScroll: avg(sum('scroll_sum'), en), + }, + series: days.map((day) => { + const t = perDay.get(day); + return { day, pv: t.pv, visitors: t.visitors, contacts: t.contacts, avgSec: avg(t.sec, t.en) }; + }), + }; +} + +// Frühester Berliner Tag mit Daten dieser Website ('' = keine): kleinster Tag aus daily und events_raw. +function firstDayOf(db, site) { + const fromDaily = db.prepare('SELECT MIN(day) AS d FROM daily WHERE site = ?').get(site)?.d || ''; + const ts = db.prepare('SELECT MIN(ts) AS t FROM events_raw WHERE site = ?').get(site)?.t; + const fromRaw = ts == null ? '' : dayOf(ts); + if (!fromDaily) return fromRaw; + if (!fromRaw) return fromDaily; + return fromDaily < fromRaw ? fromDaily : fromRaw; +} + +// Der Zeitraum sind die `range` abgeschlossenen Kalendertage, die gestern enden (wie GA4 "Letzte 28 Tage"): Der +// angebrochene heutige Tag würde gegen einen vollen Vorzeitraum als Einbruch erscheinen. Heute steht getrennt in `today`. +export function computeStats(db, site, range, now = Date.now()) { + const todayDay = dayOf(now); + const days = dayList(shiftDay(todayDay, -1), range); + const cur = collect(db, site, days, now); + const { banner, totals, series } = shape(cur); + // Heute bis jetzt: live aus den Rohdaten (der Tag ist noch nicht verdichtet) + const t = shape(collect(db, site, [todayDay], now)).series[0]; + // Vorzeitraum: die `range` Kalendertage direkt vor dem aktuellen Zeitraum (kalendarisch, nie now - range * 24 h) + const prevDays = dayList(shiftDay(days[0], -1), range); + const prevCollected = collect(db, site, prevDays, now); + const prev = shape(prevCollected); + const firstDay = firstDayOf(db, site); + return { + range, + from: days[0], + to: days[days.length - 1], + firstDay, + totals, + series, + today: { day: todayDay, pv: t.pv, visitors: t.visitors, contacts: t.contacts }, + pages: top(cur.dims.pv_path || new Map()), + refs: top(cur.dims.pv_ref || new Map()), + countries: top(cur.dims.pv_country || new Map()), + devices: top(cur.dims.pv_device || new Map()), + contacts: top(cur.dims.ct_kind || new Map()), + banner, + previous: { + from: prevDays[0], + to: prevDays[prevDays.length - 1], + complete: firstDay !== '' && firstDay <= prevDays[0], + totals: prev.totals, + series: prev.series, + contacts: top(prevCollected.dims.ct_kind || new Map()), }, - series: days.map((day) => ({ day, pv: pvByDay.get(day) })), - pages: top(dims.pv_path || new Map()), - refs: top(dims.pv_ref || new Map()), - countries: top(dims.pv_country || new Map()), - devices: top(dims.pv_device || new Map()), - contacts, - banner, }; } diff --git a/analytics/test/client.test.js b/analytics/test/client.test.js index 912b566..e438971 100644 --- a/analytics/test/client.test.js +++ b/analytics/test/client.test.js @@ -269,7 +269,7 @@ test('w.js: Banner-Aufbau (Dialog, Texte, gleiche Buttons, Akzent nur Annehmen)' assert.match(css, /button\{[^}]*background:#0a5a8a[^}]*color:#fff/); assert.match(css, /button\{[^}]*[; ]width:150px/); assert.equal((css.match(/(? NOW) { return { get, close: () => server.close() }; } -test('Stats-Endpunkt: Form, Serie lückenlos, heute live + Vergangenheit aus daily, consentRate', async () => { +test('Stats-Endpunkt: abgeschlossene Tage bis gestern, heute getrennt in today, Vergangenheit aus daily, consentRate', async () => { const { db, ev } = mk(); const y = NOW - D; for (let i = 0; i < 3; i++) ev({ ts: y + i, type: 'pv', path: '/a', vp: 'd', ref: 'g.de', country: 'DE', device: 'd' }); ev({ ts: y + 10, type: 'en', path: '/a', vp: 'd', visitor: 'v1', sec: 5, scroll: 5 }); ev({ ts: y + 11, type: 'bn', kind: 'accept' }); + ev({ ts: y + 12, type: 'ct', path: '/a', visitor: 'v1', kind: 'tel' }); runNightly(db, { now: NOW }); // gestern -> daily ev({ ts: NOW - H, type: 'pv', path: '/b', vp: 'm', country: 'AT', device: 'm' }); // heute live ev({ ts: NOW - H, type: 'ct', path: '/b', visitor: 'v9', kind: 'mail' }); @@ -132,38 +133,193 @@ test('Stats-Endpunkt: Form, Serie lückenlos, heute live + Vergangenheit aus dai const r = await a.get(`/a/admin/stats/${KEY}?range=7`); assert.equal(r.status, 200); const j = await r.json(); - assert.deepEqual(Object.keys(j).sort(), ['banner', 'contacts', 'countries', 'devices', 'pages', 'refs', 'series', 'totals']); + assert.deepEqual(Object.keys(j).sort(), ['banner', 'contacts', 'countries', 'devices', 'firstDay', 'from', 'pages', 'previous', 'range', 'refs', 'series', 'to', 'today', 'totals']); + // Der Zeitraum sind die 7 abgeschlossenen Tage bis gestern (3.–9.10.); heute (10.10.) steht nur in `today` assert.equal(j.series.length, 7); - assert.equal(j.series[0].day, '2026-10-04'); - assert.equal(j.series[6].day, '2026-10-10'); - assert.deepEqual(j.series.map((s) => s.pv), [0, 0, 0, 0, 0, 3, 1]); - assert.deepEqual(j.totals, { pv: 4, visitors: 2, contacts: 1, consentRate: 2 / 4 }); - assert.deepEqual(j.pages, [{ key: '/a', count: 3 }, { key: '/b', count: 1 }]); + assert.equal(j.series[0].day, '2026-10-03'); + assert.equal(j.series[6].day, '2026-10-09'); + assert.deepEqual(j.series.map((s) => s.pv), [0, 0, 0, 0, 0, 0, 3]); + assert.deepEqual(j.totals, { pv: 3, visitors: 1, contacts: 1, consentRate: 1, engaged: 1, avgSec: 5, avgScroll: 5 }); + assert.deepEqual(j.series[6], { day: '2026-10-09', pv: 3, visitors: 1, contacts: 1, avgSec: 5 }); + assert.deepEqual(j.today, { day: '2026-10-10', pv: 1, visitors: 1, contacts: 1 }); + assert.equal(j.from, '2026-10-03'); assert.equal(j.to, '2026-10-09'); assert.equal(j.range, 7); + assert.deepEqual(j.pages, [{ key: '/a', count: 3 }]); assert.deepEqual(j.refs[0], { key: 'g.de', count: 3 }); - assert.deepEqual(j.countries, [{ key: 'DE', count: 3 }, { key: 'AT', count: 1 }]); - assert.deepEqual(j.devices, [{ key: 'd', count: 3 }, { key: 'm', count: 1 }]); - assert.deepEqual(j.contacts, [{ key: 'mail', count: 1 }]); - assert.deepEqual(j.banner, { shown: 1, accept: 2, reject: 2 }); + assert.deepEqual(j.countries, [{ key: 'DE', count: 3 }]); + assert.deepEqual(j.devices, [{ key: 'd', count: 3 }]); + assert.deepEqual(j.contacts, [{ key: 'tel', count: 1 }]); + assert.deepEqual(j.banner, { shown: 0, accept: 1, reject: 0 }); // range 90: 90 Tage const j90 = await (await a.get(`/a/admin/stats/${KEY}?range=90`)).json(); assert.equal(j90.series.length, 90); - assert.equal(j90.totals.pv, 4); + assert.equal(j90.series[89].day, '2026-10-09'); + assert.equal(j90.totals.pv, 3); + assert.deepEqual(j90.today, j.today); a.close(); }); +test('Stats: today = heutiger Berliner Tag bis jetzt, auch ohne Ereignisse; Mitternacht in Berlin trennt gestern und heute', async () => { + const { db, ev } = mk(); + const a = await admin(db); + assert.deepEqual((await (await a.get(`/a/admin/stats/${KEY}?range=7`)).json()).today, { day: '2026-10-10', pv: 0, visitors: 0, contacts: 0 }); + a.close(); + // 11.10. 00:10 Berlin (= 10.10. 22:10 UTC, Sommerzeit): 23:00 Berlin am 10.10. gehört zu gestern, 00:05 Berlin am 11.10. zu heute + const LATE = Date.UTC(2026, 9, 10, 22, 10); + ev({ ts: Date.UTC(2026, 9, 10, 21, 0), type: 'pv', path: '/', vp: 'd', country: 'DE', device: 'd' }); + ev({ ts: Date.UTC(2026, 9, 10, 22, 5), type: 'pv', path: '/', vp: 'd', country: 'DE', device: 'd' }); + ev({ ts: Date.UTC(2026, 9, 10, 22, 6), type: 'pv', path: '/', vp: 'd', country: 'DE', device: 'd' }); + ev({ ts: Date.UTC(2026, 9, 10, 22, 7), type: 'ct', path: '/', visitor: 'v1', kind: 'wa' }); + ev({ ts: Date.UTC(2026, 9, 10, 23, 0), type: 'pv', path: '/', vp: 'd', country: 'DE', device: 'd' }); // noch in der Zukunft: zählt nicht + const b = await admin(db, () => LATE); + const j = await (await b.get(`/a/admin/stats/${KEY}?range=7`)).json(); + assert.equal(j.to, '2026-10-10'); + assert.equal(j.series[6].day, '2026-10-10'); assert.equal(j.series[6].pv, 1); + assert.equal(j.totals.pv, 1); + assert.deepEqual(j.today, { day: '2026-10-11', pv: 2, visitors: 1, contacts: 1 }); + assert.equal(j.contacts.length, 0); // die Kontakt-Aktion von 00:07 ist heute + b.close(); +}); + test('Stats: gestern noch nicht verdichtet wird live gezählt, nie doppelt', async () => { const { db, ev } = mk(); ev({ ts: NOW - D, type: 'pv', path: '/', vp: 'd', country: 'DE', device: 'd' }); + ev({ ts: NOW - H, type: 'pv', path: '/', vp: 'd', country: 'DE', device: 'd' }); // heute: nur in today const a = await admin(db); - assert.equal((await (await a.get(`/a/admin/stats/${KEY}?range=7`)).json()).totals.pv, 1); + const stats = async () => (await (await a.get(`/a/admin/stats/${KEY}?range=7`)).json()); + assert.equal((await stats()).totals.pv, 1); runNightly(db, { now: NOW }); - assert.equal((await (await a.get(`/a/admin/stats/${KEY}?range=7`)).json()).totals.pv, 1); + const j = await stats(); + assert.equal(j.totals.pv, 1); + assert.equal(j.today.pv, 1); + a.close(); +}); + +test('Stats: Vorzeitraum = die range Kalendertage vor dem aktuellen Zeitraum, complete nach firstDay', async () => { + const { db, ev } = mk(); + // aktueller Zeitraum zu range 7 am 10.10.: 03.–09.10.; Vorzeitraum: 26.09.–02.10. + ev({ ts: Date.UTC(2026, 8, 30, 10), type: 'pv', path: '/', vp: 'd', country: 'DE', device: 'd' }); + ev({ ts: Date.UTC(2026, 9, 1, 10), type: 'en', path: '/', vp: 'd', visitor: 'v1', sec: 20, scroll: 40 }); + ev({ ts: Date.UTC(2026, 9, 2, 10), type: 'ct', path: '/', visitor: 'v2', kind: 'tel' }); + const a = await admin(db); + const get = async () => (await (await a.get(`/a/admin/stats/${KEY}?range=7`)).json()); + let j = await get(); + assert.equal(j.firstDay, '2026-09-30'); + assert.equal(j.previous.complete, false); + // 27.09. liegt noch einen Tag nach dem Beginn des Vorzeitraums (26.09.): weiter unvollständig + ev({ ts: Date.UTC(2026, 8, 27, 10), type: 'pv', path: '/x', vp: 'd', country: 'DE', device: 'd' }); + j = await get(); + assert.equal(j.firstDay, '2026-09-27'); + assert.equal(j.previous.complete, false); + // genau am ersten Tag des Vorzeitraums: vollständig + ev({ ts: Date.UTC(2026, 8, 26, 10), type: 'pv', path: '/', vp: 'd', country: 'DE', device: 'd' }); + j = await get(); + assert.equal(j.firstDay, '2026-09-26'); + assert.equal(j.previous.complete, true); + // 25.09. liegt vor dem Vorzeitraum und darf nicht mitzählen + ev({ ts: Date.UTC(2026, 8, 25, 10), type: 'pv', path: '/', vp: 'd', country: 'DE', device: 'd' }); + j = await get(); + assert.equal(j.firstDay, '2026-09-25'); + assert.equal(j.previous.complete, true); + assert.equal(j.previous.from, '2026-09-26'); assert.equal(j.previous.to, '2026-10-02'); + assert.equal(j.previous.series.length, 7); assert.equal(j.previous.series[4].day, '2026-09-30'); + assert.equal(j.previous.totals.pv, 3); // 26.09., 27.09., 30.09. + assert.equal(j.previous.totals.engaged, 1); + assert.equal(j.previous.totals.avgSec, 20); + assert.equal(j.previous.totals.contacts, 1); + assert.deepEqual(j.previous.series[5], { day: '2026-10-01', pv: 0, visitors: 1, contacts: 0, avgSec: 20 }); + assert.deepEqual(j.previous.series[6], { day: '2026-10-02', pv: 0, visitors: 1, contacts: 1, avgSec: null }); + assert.deepEqual(j.previous.contacts, [{ key: 'tel', count: 1 }]); + assert.equal(j.totals.pv, 0); // aktueller Zeitraum leer + assert.equal(j.totals.avgSec, null); + a.close(); +}); + +test('Stats: firstDay aus daily und Rohdaten, leer ohne Daten', async () => { + const { db, ev } = mk(); + const a = await admin(db); + const first = async () => (await (await a.get(`/a/admin/stats/${KEY}?range=7`)).json()); + let j = await first(); + assert.equal(j.firstDay, ''); + assert.equal(j.previous.complete, false); + // nur Rohdaten (heute) + ev({ ts: NOW - H, type: 'pv', path: '/', vp: 'd', country: 'DE', device: 'd' }); + assert.equal((await first()).firstDay, '2026-10-10'); + // 40 Tage alt: nach dem Nachtlauf nur noch in daily (Rohdaten > 30 Tage gelöscht) + ev({ ts: NOW - 40 * D, type: 'pv', path: '/', vp: 'd', country: 'DE', device: 'd' }); + assert.equal((await first()).firstDay, '2026-08-31'); + runNightly(db, { now: NOW }); + assert.equal(db.prepare('SELECT COUNT(*) c FROM events_raw WHERE ts < ?').get(NOW - 31 * D).c, 0); + j = await first(); + assert.equal(j.firstDay, '2026-08-31'); + assert.equal(j.previous.complete, true); + a.close(); +}); + +test('Stats: avgSec/avgScroll gerundet, null ohne en; Tageswert avgSec', async () => { + const { db, ev } = mk(); + const t = NOW - D; // 9.10. + ev({ ts: t, type: 'en', path: '/', vp: 'd', visitor: 'v1', sec: 30, scroll: 50 }); + ev({ ts: t + 1, type: 'en', path: '/a', vp: 'd', visitor: 'v2', sec: 91, scroll: 100 }); + const a = await admin(db); + const j = await (await a.get(`/a/admin/stats/${KEY}?range=7`)).json(); + assert.equal(j.totals.engaged, 2); + assert.equal(j.totals.avgSec, 61); // 60,5 gerundet + assert.equal(j.totals.avgScroll, 75); + assert.equal(j.series[6].avgSec, 61); // 9.10. ist der letzte abgeschlossene Tag + assert.equal(j.series[5].avgSec, null); + // ohne en: beide null, engaged 0 + const leer = await (await a.get(`/a/admin/stats/${KEY}?range=30`)).json(); + assert.equal(leer.previous.totals.engaged, 0); + assert.equal(leer.previous.totals.avgSec, null); + assert.equal(leer.previous.totals.avgScroll, null); + a.close(); +}); + +test('Stats: Vorzeitraum über die Zeitumstellung lückenlos und ohne Überlappung', async () => { + // range 30 um 23:30 Berlin: Zeitumstellung am 25.10. liegt im Zeitraum. Spätabends wäre now - 30 * 24 h + // schon der 12.10. (00:30 Berlin) und ein naives Fenster würde sich überlappen; mittags fiele das nicht auf. + const NOV = Date.UTC(2026, 10, 10, 22, 30); + const { db, ev } = mk(); + ev({ ts: Date.UTC(2026, 9, 10, 10), type: 'pv', path: '/', vp: 'd', country: 'DE', device: 'd' }); // 10.10. mittags: Vorzeitraum + ev({ ts: Date.UTC(2026, 9, 11, 10), type: 'pv', path: '/', vp: 'd', country: 'DE', device: 'd' }); // 11.10. mittags: aktueller Zeitraum + ev({ ts: Date.UTC(2026, 10, 10, 12), type: 'pv', path: '/', vp: 'd', country: 'DE', device: 'd' }); // 10.11. mittags: heute + const a = await admin(db, () => NOV); + const j = await (await a.get(`/a/admin/stats/${KEY}?range=30`)).json(); + assert.equal(j.series[0].day, '2026-10-11'); + assert.equal(j.series[29].day, '2026-11-09'); + assert.equal(j.previous.series[29].day, '2026-10-10'); + assert.equal(j.previous.series[0].day, '2026-09-11'); + assert.equal(j.from, '2026-10-11'); assert.equal(j.to, '2026-11-09'); + assert.equal(j.previous.from, '2026-09-11'); assert.equal(j.previous.to, '2026-10-10'); + const all = [...j.series, ...j.previous.series].map((s) => s.day); + assert.equal(all.length, 60); + assert.equal(new Set(all).size, 60); + assert.equal(j.previous.totals.pv, 1); assert.equal(j.previous.series[29].pv, 1); + assert.equal(j.totals.pv, 1); assert.equal(j.series[0].pv, 1); + assert.equal(j.today.day, '2026-11-10'); assert.equal(j.today.pv, 1); + a.close(); +}); + +test('Stats: Vorzeitraum aus daily und live, nie doppelt', async () => { + const { db, ev } = mk(); + ev({ ts: Date.UTC(2026, 9, 1, 10), type: 'pv', path: '/', vp: 'd', country: 'DE', device: 'd' }); + ev({ ts: Date.UTC(2026, 9, 1, 11), type: 'ct', path: '/', visitor: 'v1', kind: 'mail' }); + const a = await admin(db); + const get = async () => (await (await a.get(`/a/admin/stats/${KEY}?range=7`)).json()).previous; + const live = await get(); // noch nicht verdichtet: live aus den Rohdaten + assert.equal(live.totals.pv, 1); + runNightly(db, { now: NOW }); // jetzt aus daily + const verdichtet = await get(); + assert.equal(verdichtet.totals.pv, 1); + assert.deepEqual(verdichtet, live); + runNightly(db, { now: NOW + H }); + assert.deepEqual(await get(), live); a.close(); }); test('Stats: Top 20 absteigend, consentRate 0 ohne Entscheidungen, Fehlerfälle', async () => { const { db, ev } = mk(); - for (let p = 0; p < 25; p++) for (let i = 0; i <= p; i++) ev({ ts: NOW - 1000, type: 'pv', path: `/p${p}`, vp: 'd', country: 'DE', device: 'd' }); + for (let p = 0; p < 25; p++) for (let i = 0; i <= p; i++) ev({ ts: NOW - D - 1000, type: 'pv', path: `/p${p}`, vp: 'd', country: 'DE', device: 'd' }); const a = await admin(db); const j = await (await a.get(`/a/admin/stats/${KEY}?range=30`)).json(); assert.equal(j.pages.length, 20); diff --git a/docs/WEBklar-Datenschutzerklaerung.pdf b/docs/WEBklar-Datenschutzerklaerung.pdf index d94699e..8ea07dd 100644 Binary files a/docs/WEBklar-Datenschutzerklaerung.pdf and b/docs/WEBklar-Datenschutzerklaerung.pdf differ diff --git a/docs/WEBklar-Datenschutzerklaerung.pdf.sha256 b/docs/WEBklar-Datenschutzerklaerung.pdf.sha256 new file mode 100644 index 0000000..99cdc82 --- /dev/null +++ b/docs/WEBklar-Datenschutzerklaerung.pdf.sha256 @@ -0,0 +1 @@ +d73389a0cf78380029948d91db022e20d7d2f7182ea1c4fa78478d78c719c938 diff --git a/docs/funktionen/shop-datenschutz-absatz.md b/docs/funktionen/shop-datenschutz-absatz.md new file mode 100644 index 0000000..99709b2 --- /dev/null +++ b/docs/funktionen/shop-datenschutz-absatz.md @@ -0,0 +1,55 @@ +# Mustertext: Datenschutzerklärung der Kundenwebsite (Webshop) + +**Muster – keine Rechtsberatung.** Der Absatz gehört in die Datenschutzerklärung jeder Kundenwebsite, auf der der Webshop läuft. Der Händler ist Verantwortlicher und verantwortet seine Datenschutzerklärung selbst; WEBklar liefert nur diesen Baustein (Leistungsbeschreibung Webshop Nr. 5 Abs. 10 und Nr. 6). Bau den Shop erst ein, wenn der Absatz in der Datenschutzerklärung steht. + +Hinweise für das Team: + +- Liegt die Datenschutzerklärung in der Website, die WEBklar betreut, fügen wir den Absatz beim Einbau ein (gehört zur Einrichtung). Liegt sie woanders, bekommt der Händler den Text im Chat und fügt ihn selbst ein. +- Platzhalter in eckigen Klammern durch die Angaben des Händlers ersetzen. Nicht Zutreffendes streichen: Klarna nur, wenn der Händler Klarna in den Zahlungsarten eingeschaltet hat; den Satz zum Versanddienstleister nur, wenn er versendet; die Zeile zur Abholung nur bei Abholung. +- Anrede und Ton an die übrige Erklärung der Website anpassen (meist Sie). Die Nummerierung der Überschrift der Website beachten. +- Wenn der Händler im Impressum oder in der Erklärung einen Datenschutzbeauftragten nennt, dort dazuschreiben. +- Die Löschfristen entsprechen dem AVV Webshop Nr. 9 und Abschnitt 6d der Datenschutzerklärung von WEBklar (project.webklar.com/datenschutz.html). Ändert sich dort etwas, hier mitändern. Die Frist für Server-Protokolle (14 Tage) steht in Abschnitt 3 dieser Erklärung. +- Das Widget setzt keine Cookies. Der Warenkorb liegt in der `sessionStorage` des Tabs, das ist Absatz „Warenkorb im Browser“. +- Im sichtbaren Text der Kundenwebsite die Wünsche des Händlers beachten (etwa keine Gedankenstriche). + +--- + +## [Nummer]. Bestellungen in unserem Webshop + +### Verantwortlicher und technischer Betrieb + +Verantwortlich für die Verarbeitung Ihrer Daten bei einer Bestellung ist [Name des Händlers, Anschrift, E-Mail-Adresse], also der Verkäufer. Den Webshop betreibt technisch die WEBklar GbR, Am Schwimmbad 10, 67722 Winnweiler, in unserem Auftrag. Sie verarbeitet Ihre Daten nur nach unseren Weisungen und nicht für eigene Zwecke (Auftragsverarbeitung nach Art. 28 DSGVO). Der Server steht bei der Hetzner Online GmbH in Falkenstein (Deutschland); der Versand der E-Mails zu Ihrer Bestellung läuft über den E-Mail-Dienst der Hetzner Online GmbH in Nürnberg (Deutschland). + +### Welche Daten wir bei einer Bestellung verarbeiten + +Wenn Sie in unserem Webshop bestellen, verarbeiten wir die Artikel in Ihrem Warenkorb und Ihre Bestellung (Artikel, Preise, Versandart, Gesamtbetrag, Bestellnummer, Zeitpunkt), Ihren Namen, Ihre E-Mail-Adresse sowie Ihre Liefer- und Rechnungsanschrift, den Stand der Zahlung und den Stand Ihrer Bestellung (zum Beispiel bezahlt, versendet, abholbereit, erstattet). Name und Anschriften geben Sie auf der Zahlungsseite von Stripe ein (siehe unten); wir erhalten sie von dort, die Rechnungsanschrift nur, wenn Stripe sie für Ihre Zahlungsart abfragt. Eine Telefonnummer verlangen und speichern wir nicht. Die Verarbeitung ist nötig, um den Kaufvertrag mit Ihnen zu schließen und zu erfüllen (Art. 6 Abs. 1 lit. b DSGVO). Ohne diese Angaben können wir Ihre Bestellung nicht ausführen. Außerdem speichern wir in der noch offenen Bestellung einen Hashwert Ihrer IP-Adresse (nicht die IP-Adresse selbst; er wird mit einem geheimen Schlüssel des Servers berechnet, sodass sich daraus keine IP-Adresse ableiten lässt). Wir lassen damit je IP-Adresse höchstens drei offene, noch nicht bezahlte Bestellungen innerhalb von 30 Minuten zu und schützen den Webshop so vor automatisierten Massenbestellungen, die den Bestand blockieren. Das ist unser berechtigtes Interesse (Art. 6 Abs. 1 lit. f DSGVO); Sie können dieser Verarbeitung widersprechen (Art. 21 DSGVO). Wir verwenden den Hashwert nur dafür und löschen ihn, sobald Ihre Bestellung bezahlt oder storniert ist, spätestens mit der Löschung nicht bezahlter Bestellungen nach 30 Tagen. + +### Zahlung über Stripe + +Die Zahlung wickelt die Stripe Payments Europe, Limited, 1 Grand Canal Street Lower, Grand Canal Dock, Dublin, D02 H210, Irland („Stripe“) ab. Sie werden dazu auf die Zahlungsseite von Stripe weitergeleitet. Karten- und Kontodaten geben Sie nur dort ein; wir und der Betreiber des Webshops erhalten sie nicht. Stripe verarbeitet Ihre Daten teils im Rahmen der Zahlungsabwicklung für uns und teils als eigener Verantwortlicher, zum Beispiel zur Betrugsvorbeugung und zur Erfüllung gesetzlicher Pflichten; dafür gilt die Datenschutzerklärung von Stripe (stripe.com/de/privacy). 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. Rechtsgrundlage ist Art. 6 Abs. 1 lit. b DSGVO. + +Welche Zahlungsarten angeboten werden, sehen Sie vor dem Kauf im Webshop. Zahlen Sie per SEPA-Lastschrift, gibt Stripe Ihre Kontodaten an die beteiligten Banken weiter und bucht den Betrag nach Ihrem Mandat ab. [Nur wenn Klarna eingeschaltet ist: Wählen Sie Klarna, übermittelt Stripe die zur Zahlung erforderlichen Daten an die Klarna Bank AB (publ), Sveavägen 46, 111 34 Stockholm, Schweden; es gelten die Datenschutzbestimmungen von Klarna, die Ihnen bei der Zahlung angezeigt werden.] + +### Bestellbestätigung und weitere E-Mails + +Zu Ihrer Bestellung senden wir Ihnen E-Mails an die angegebene E-Mail-Adresse: bei einer Zahlung, die noch aussteht (zum Beispiel SEPA-Lastschrift), zuerst eine Mail über den Eingang der Bestellung, Bestellbestätigung (mit den Vertragsbedingungen, der Widerrufsbelehrung und dem Muster-Widerrufsformular; es sind die Texte, die bei Ihrer Bestellung galten), Hinweise zum Versand [oder zur Abholbereitschaft], Bestätigungen zu einer Erstattung oder zu einem Widerruf und, wenn eine Zahlung nicht gelungen ist, einen Hinweis darauf. Das sind Mitteilungen zur Abwicklung Ihres Vertrags (Art. 6 Abs. 1 lit. b DSGVO), keine Werbung. Bis zum Versand liegt jede E-Mail in einer Warteschlange; nach dem Versand wird Empfängeradresse und Inhalt dort gelöscht, der Eintrag selbst nach sieben Tagen. + +### Warenkorb im Browser + +Ihren Warenkorb (Artikel und Mengen, keine persönlichen Daten) speichert der Webshop im Sitzungsspeicher (sessionStorage) Ihres Browsers, solange der Tab geöffnet ist. Das ist technisch erforderlich, damit der Warenkorb beim Durchklicken erhalten bleibt (§ 25 Abs. 2 Nr. 2 TDDDG); es werden keine Cookies gesetzt, und der Eintrag verschwindet, wenn Sie den Tab schließen. Beim Aufruf der Seite lädt Ihr Browser das Programm des Webshops und die Artikeldaten von der Adresse project.webklar.com. Dabei werden Ihre IP-Adresse, Datum und Uhrzeit des Aufrufs sowie Angaben zu Ihrem Browser und Betriebssystem übertragen und für 14 Tage in Server-Protokollen gespeichert, um den sicheren und störungsfreien Betrieb zu gewährleisten (Art. 6 Abs. 1 lit. f DSGVO). Zum Schutz vor Missbrauch begrenzen wir die Zahl der Bestellungen und Widerrufe je IP-Adresse; die Zeitpunkte der Aufrufe werden nur im Arbeitsspeicher des Servers gehalten. Mit Ihrer Bestellung oder Ihrem Widerruf wird Ihre IP-Adresse nicht gespeichert. + +### Widerruf über die Widerrufsfunktion + +Über den Link „Vertrag widerrufen“ können Sie Ihren Vertrag ohne Anmeldung online widerrufen. Dabei verarbeiten wir Ihren Namen, Ihre E-Mail-Adresse, die Bestellnummer, Ihre Erklärung und den Zeitpunkt des Eingangs. Sie erhalten sofort eine Eingangsbestätigung mit Inhalt, Datum und Uhrzeit Ihrer Erklärung. Rechtsgrundlage ist Art. 6 Abs. 1 lit. c DSGVO in Verbindung mit § 356a BGB sowie Art. 6 Abs. 1 lit. b DSGVO. + +### Weitergabe + +[Nur bei Versand: Zur Lieferung geben wir Ihren Namen und Ihre Lieferanschrift an das Versandunternehmen weiter, das wir beauftragen ([Name des Versanddienstleisters]).] Im Übrigen geben wir Ihre Daten nur weiter, soweit das für die Vertragsabwicklung nötig ist (Stripe und die genannten Zahlungsanbieter, Auftragsverarbeiter) oder wir gesetzlich dazu verpflichtet sind (zum Beispiel an das Finanzamt). Eine Weitergabe zu Werbezwecken findet nicht statt. + +### Speicherdauer + +Bestellungen, die nicht bezahlt oder die storniert wurden, löscht das Webshop-System nach 30 Tagen. Für bezahlte Bestellungen sind wir als Händler verpflichtet, Buchungsbelege und Geschäftsunterlagen nach Handels- und Steuerrecht aufzubewahren (in der Regel sechs bis zehn Jahre, § 257 HGB, § 147 AO; Art. 6 Abs. 1 lit. c DSGVO). Diese Unterlagen führen wir selbst und mit den Belegen von Stripe, nicht im Webshop-System. Im Webshop-System selbst werden Name, E-Mail-Adresse und Anschriften drei Jahre nach dem Ende des Jahres der Bestellung anonymisiert; die Bestellnummer, die Artikel und die Beträge bleiben ohne Personenbezug erhalten. Daten aus einem Widerruf werden ebenso behandelt wie die zugehörige Bestellung. + +### Ihre Rechte + +Sie haben das Recht auf Auskunft über Ihre Daten (Art. 15 DSGVO), auf Berichtigung (Art. 16), Löschung (Art. 17), Einschränkung der Verarbeitung (Art. 18), Datenübertragbarkeit (Art. 20) und auf Widerspruch gegen Verarbeitungen, die auf Art. 6 Abs. 1 lit. f DSGVO beruhen (Art. 21). Wenden Sie sich dazu an uns unter [E-Mail-Adresse des Händlers]; wir leiten Ihre Anfrage, soweit nötig, an den technischen Betreiber des Webshops weiter. Außerdem können Sie sich bei einer Datenschutz-Aufsichtsbehörde beschweren, etwa bei der für uns zuständigen Behörde [zuständige Aufsichtsbehörde des Händlers]. diff --git a/docs/funktionen/shop-einbau.md b/docs/funktionen/shop-einbau.md new file mode 100644 index 0000000..39e084a --- /dev/null +++ b/docs/funktionen/shop-einbau.md @@ -0,0 +1,98 @@ +# Webshop auf der Kundenwebsite einbauen + +Anleitung für das WEBklar-Team. Das Widget lässt Besucher auf der Website des Kunden („Händler“) Waren bestellen und bezahlen. Artikel, Versand, Zahlungsarten und Rechtstexte pflegt der Händler im Kundenportal (Tab „Funktionen“, Kachel „Webshop“). Den `siteKey` (16 Zeichen, a–z und 2–7) findest Du im Einrichtungs-Ticket und im Ticketsystem (WOMS): Kunde → Reiter „Funktionen“, Zeile Webshop. Im Ticket stehen Snippet und Schritte schon fertig; dieses Dokument ist die Referenz dahinter. Ohne aktive Funktion, ohne verbundenes Stripe-Konto, ohne bestätigte Rechtstexte oder ohne lieferbaren Artikel ist nichts bestellbar: Das Widget zeigt dann „Der Shop ist gerade geschlossen“ (oder den Ausweichtext, siehe Abschnitt 4). + +Der Webshop ist an den Vertrag über den Betrieb der Website gekoppelt (Leistungsbeschreibung Webshop Nr. 10), aber **nicht in der Software**: Das Portal liest keine Abo-Daten für den Shop, schaltet ihn nicht von selbst ab, und bestellt werden kann er auch ohne Abo. Das Abschalten zum Vertragsende machen wir von Hand (Abschnitt 8). + +## 1. Das Snippet + +```html +
+

Unser Shop ist gerade nicht erreichbar. Bitte schreiben Sie uns: info@beispiel.de

+
+ +``` + +Das Skript steht einmal pro Seite, vor ``. Es hat keine Abhängigkeiten, setzt keine Cookies, lädt nichts von Dritten und fragt nur `project.webklar.com` an. Einziger Speicher im Browser ist der Warenkorb (nur Artikelkennungen und Mengen) in der `sessionStorage` des Tabs; er ist technisch erforderlich (§ 25 Abs. 2 Nr. 2 TDDDG) und verschwindet mit dem Tab. Texte des Händlers setzt das Skript nur als Text ein, nie als HTML. Das Widget rechnet nie selbst: Preise, Summen, Versand und Steuerhinweise kommen fertig vom Server. + +| Attribut | Pflicht | Bedeutung | +| --- | --- | --- | +| `data-webklar-shop` | ja | der `siteKey` des Projekts | +| `data-api` | nein | Herkunft des Portals, Standard `https://project.webklar.com`. Bei Modul-Import, Tag-Manager oder nachgeladenem Skript ist `data-api` Pflicht. | +| `data-ansicht` | nein | `widerruf` öffnet gleich die Widerrufsfunktion („Vertrag widerrufen“). Die Adresse der Seite kennt außerdem `?ansicht=widerruf` und `?ansicht=agb`. | + +## 2. Wohin damit + +1. **Eigene Seite „Shop“** anlegen (zum Beispiel `/shop` oder `/shop.html`) und das Snippet an die Stelle des Inhalts setzen. Das `div` braucht Breite (normaler Blockcontainer; in einem Flex- oder Grid-Element ohne Breite schrumpft es auf null). Der Warenkorb liegt nur im Tab, also darf die Seite beim Bestellen nicht in einem iframe liegen und der Händler braucht keine zweite Seite für die Kasse: Kasse, Danke-Seite und Widerruf laufen im selben Widget. +2. **Menüpunkt „Shop“** im Hauptmenü auf diese Seite, auf allen Seiten. +3. Vom Shop zurück: Nach der Zahlung bei Stripe kehrt der Käufer auf die Seite zurück, auf der er bestellt hat (nur Adressen der Website des Projekts sind erlaubt, sonst die Standalone-Seite). Die Seite darf also nicht hinter einer Weiterleitung oder einem Passwortschutz liegen. +4. **Mehrere Widgets** auf einer Seite sind nicht vorgesehen. Der Shop ist ein Widget je Website. + +Aussehen: Das Widget nimmt die Akzentfarbe aus den Einstellungen des Händlers. Wie beim Terminplaner stehen die Stile in `@layer wks`, jede Regel der Kundenseite gewinnt also. Anpassen per CSS-Variablen auf dem `div`, in der Seiten-CSS oder auf `:root`, zum Beispiel `[data-webklar-shop]{ --wks-akzent:#0f766e; --wks-akzent-text:#fff; }`: + +| Variable | Standard | +| --- | --- | +| `--wks-akzent` und `--wks-akzent-text` (Knöpfe; setze beide zusammen) | Akzentfarbe des Händlers, sonst `#1d4ed8` und `#fff` | +| `--wks-hintergrund` | `#fff` | +| `--wks-text` | `#1f2937` | +| `--wks-rand` | `#858f9e` | +| `--wks-radius` | `10px` | +| `--wks-schrift`, `--wks-schrift-titel` | Schrift der Seite | + +Kontrast (WCAG 2.1 AA): Text auf dem Hintergrund und Schrift auf dem Akzent mindestens 4,5 : 1, Rand gegen den Hintergrund mindestens 3 : 1. Prüfe beim Abnehmen, ob globale Regeln des Themes (`button{…}`, `input{…}`) das Widget stören, und überschreibe sie gezielt (`[data-webklar-shop] .wks-btn{ … }`). + +## 3. Link „Vertrag widerrufen“ auf jeder Seite (Pflicht) + +Seit dem 19.06.2026 muss jeder Online-Shop eine elektronische Widerrufsfunktion bieten (§ 356a BGB). Sie muss **auf jeder Seite** der Website dauerhaft erreichbar sein, nicht nur im Shop: + +- In der **Fußzeile jeder Seite** (auch Impressum, Datenschutz und 404-Seite) steht der Link **„Vertrag widerrufen“** auf `https://project.webklar.com/shop//widerruf` (mit dem echten `siteKey`). +- Der Linktext lautet genau „Vertrag widerrufen“. Er darf nicht ausgeblendet, versteckt oder in einem Aufklappmenü untergebracht werden. +- Der Link braucht keine Anmeldung. Er führt zur Schaltfläche „Vertrag widerrufen“, danach zum Formular und zur Bestätigungsschaltfläche „Widerruf bestätigen“. +- Im Shop-Widget selbst steht der Knopf „Vertrag widerrufen“ zusätzlich in der Fußzeile des Widgets. + +Der Händler darf den Link nicht entfernen oder ändern (Leistungsbeschreibung Nr. 5 Abs. 6). Weise ihn bei der Übergabe darauf hin. + +## 4. Ausweichtext + +Alles, was im `div` steht, gehört der Kundenwebsite. Das Skript ersetzt es nur, wenn die Konfiguration geladen werden konnte. Bei ausgeschaltetem JavaScript, unbekanntem Schlüssel, abgeschalteter Funktion oder Netzfehler bleibt der Text stehen. Schreib dort einen Hinweis und die Kontaktdaten des Händlers hinein. Ist der Shop zwar eingebunden, aber (noch) nicht geöffnet, zeigt das Widget selbst „Der Shop ist gerade geschlossen“. + +## 5. Vor der Abnahme prüfen + +**Impressum des Händlers.** Der Händler ist der Verkäufer. Sein Impressum muss ihn nennen (Name beziehungsweise Firma, vollständige Anschrift, E-Mail, Telefon, gegebenenfalls Register und USt-IdNr.). Es reicht nicht, wenn nur WEBklar als Betreiber der Website erscheint. Die Adressen von Impressum und Datenschutzerklärung (https) hat der Händler im Portal unter Rechtliches eingetragen; das Widget verlinkt sie in der Fußzeile und in der Kasse. Prüfe, dass beide Adressen auf die echten Seiten der Website führen. Fällt Dir ein offensichtlicher Mangel auf, sag es dem Händler im Chat (Leistungsbeschreibung Nr. 5 Abs. 10: Eine Prüfpflicht entsteht daraus nicht). + +**Datenschutz-Absatz.** Liegt die Datenschutzerklärung in der Website, die wir betreuen, fügen wir den Absatz aus `docs/funktionen/shop-datenschutz-absatz.md` dort ein (gehört zur Einrichtung). Liegt sie woanders, bekommt der Händler den Text per Chat und fügt ihn selbst ein. Anrede und Nummerierung an die übrige Erklärung anpassen; die Angaben zur Speicherdauer entsprechen dem AVV Webshop Nr. 9 und Abschnitt 6d unserer Datenschutzerklärung. Ändert sich dort etwas, ändere den Absatz mit. + +**Rechtstexte.** AGB, Widerrufsbelehrung, Muster-Widerrufsformular sowie Versand und Zahlung bestätigt der Händler im Portal (Rechtliches). Die Muster liegen in `docs/funktionen/shop-muster-rechtstexte.md`. Ohne Bestätigung öffnet der Shop nicht. + +**Artikel.** Wirf einen Blick auf die Pflichtangaben: Preis, Grundpreis (immer je 1 kg, 1 l, 1 m oder 1 m²), Lieferzeit, bei Lebensmitteln die Pflichtangaben und Allergene, die Angaben zur Produktsicherheit und Widerrufsausschlüsse. Das ist Sache des Händlers; ein Hinweis im Chat hilft. + +## 6. Vorschau und Testkauf + +1. **Vorschau:** `https://project.webklar.com/shop/` zeigt das Widget auf einer neutralen Seite. Prüfe dort Artikel, Warenkorb, Versand und die Fußzeile (AGB, Widerrufsbelehrung, Versand & Zahlung, Vertrag widerrufen, Impressum, Datenschutz). Prüfe danach dasselbe auf der echten Seite der Kundenwebsite, auch auf dem Handy. +2. **Testkauf mit dem Händler.** Lege mit dem Händler einen Artikel für **1 €** an (nur für den Test, danach wieder löschen oder deaktivieren). Der Händler öffnet den Shop (Übersicht → „Shop geöffnet“, alle Haken grün), Du kaufst den Artikel auf der echten Seite mit Deiner E-Mail-Adresse und zahlst echt (das Stripe-Konto des Händlers ist ein Live-Konto). Prüfe: Bestätigungsseite „Danke“, Bestellbestätigungsmail beim Käufer (mit AGB, Widerrufsbelehrung und Muster-Widerrufsformular im Text), Mail „Neue Bestellung“ beim Händler, Bestellung im Portal als bezahlt. +3. **Widerruf testen:** Rufe den Link „Vertrag widerrufen“ aus der Fußzeile auf, erkläre den Widerruf zur Testbestellung und bestätige ihn. Du musst sofort eine Eingangsbestätigung mit Inhalt, Datum und Uhrzeit bekommen; der Händler bekommt die Mitteilung, der Widerruf steht an der Bestellung im Portal. +4. **Erstatten:** Der Händler erstattet den Testkauf im Portal (Bestellung → Erstatten). Prüfe die Erstattungsmail und dass Stripe die Rückzahlung zeigt. Den Testartikel danach löschen. +5. Ist alles in Ordnung, **schließe das Einrichtungs-Ticket**. Das schaltet die Funktion frei (Kunde erhält die Mail „Webshop bereit“). Trag die Zeit im Arbeitsblatt ein; sie wird nicht auf Abo-Stunden angerechnet (Leistungsbeschreibung Nr. 9 Abs. 4). + +## 7. Was das Widget zeigt (Kurzüberblick) + +1. Ganz oben: Lieferländer und akzeptierte Zahlungsarten (Beginn des Bestellvorgangs, § 312j Abs. 1 BGB). +2. Artikel (Kategorien, Raster), Detail mit Bildern, Beschreibung, Preis, Grundpreis, Varianten, Lieferhinweis, Widerrufsausschluss, Produktsicherheit und Lebensmittelangaben. +3. Warenkorb, Lieferart (Versand oder Abholung). +4. Kasse: Positionen mit Einzel- und Gesamtpreis, Versandkosten, Gesamtsumme, Lieferzeit, Zahlungsarten, Hinweis auf das Widerrufsrecht beziehungsweise seinen Ausschluss, Links zu AGB und Widerrufsbelehrung, **Hinweis auf die gesetzliche Gewährleistung** (Anhang I der Verordnung (EU) 2025/1960, hervorgehoben über dem Knopf) und der Knopf **„Zahlungspflichtig bestellen“**. Erst mit diesem Klick im Widget kommt der Vertrag zustande; Stripe Checkout ist danach nur noch die Zahlung (Name, Telefon und Lieferadresse sammelt Stripe). +5. Danke-Seite mit dem Stand der Bestellung; die Bestellbestätigungsmail geht an den Käufer. +6. Widerruf: Formular (Name, E-Mail, Bestellnummer, optional Erklärung) und „Widerruf bestätigen“. + +## 8. Wenn der Vertrag über die Website endet (Abschalten von Hand) + +Der Webshop läuft, solange WEBklar die Website des Händlers betreibt (Hosting-Abo, Wartungs-Abo oder ein anderer Vertrag über den Betrieb der Website; Leistungsbeschreibung Nr. 10). Die Software prüft das **nicht**. Du musst es selbst im Blick haben, wenn ein solcher Vertrag endet, gekündigt oder widerrufen wird: + +1. **Vorher** schreibst Du dem Händler eine E-Mail (oder Chat): Der Vertrag endet am TT.MM.JJJJ, damit endet auch der Webshop, WEBklar schaltet ihn zum Vertragsende ab. Er soll offene Bestellungen und Widerrufe abarbeiten, seine Bestellungen als CSV exportieren (Aufbewahrungspflichten sind seine Sache) und seine Käufer informieren, dass der Shop nicht mehr angeboten wird. +2. **Zum Vertragsende** im Ticketsystem (WOMS): Kunde → Reiter „Funktionen“ → Webshop → **„Abschalten“**. Danach nimmt der Shop keine Bestellungen mehr an, das Widget zeigt den Ausweichtext, unbezahlte Bestellungen laufen aus. +3. Der Händler kann Bestellungen und Widerrufe danach **90 Tage** lang im Portal lesen und exportieren. Danach löscht der tägliche Job automatisch alle Shopdaten (Artikel, Bilder, Bestellungen, Widerrufe, Einstellungen); das Einrichtungs-Ticket bleibt als Arbeitsnachweis ohne Käuferdaten. Die Widerrufsfunktion bleibt bis zu dieser Löschung erreichbar, solange das Widget eingebunden ist. +4. Nimm nach dem Abschalten den Link „Vertrag widerrufen“ aus der Fußzeile und das Snippet aus der Website, sobald die 90 Tage um sind (oder der Händler den Shop nicht mehr braucht), und entferne den Shop-Menüpunkt. +5. Beginnt **innerhalb der 90 Tage** wieder ein Vertrag über den Betrieb der Website, schaltest Du den Webshop auf Wunsch des Händlers in Textform ohne weitere Kosten wieder ein (WOMS → Funktionen → Webshop → Aktivieren; Daten und Einstellungen sind noch da). Nach der Löschung ist eine neue Anfrage und Einrichtung nötig (neuer Preis). +6. **Erstattung (Leistungsbeschreibung Nr. 10 Abs. 6):** Endet der Vertrag über den Betrieb der Website, **bevor** wir den Webshop freigeschaltet haben (Ticket noch offen, Funktion im Zustand „in Einrichtung“), erstatten wir die 299 € vollständig, in Stripe von Hand. In allen anderen Fällen (Webshop war schon freigeschaltet, Abschalten auf Wunsch nach Abs. 7, Abschalten wegen verbotener Waren) gibt es keine Erstattung; nur der Widerruf der Bestellung richtet sich nach Nr. 9 Abs. 6. +7. Widerruft ein Verbraucher oder Existenzgründer die Bestellung der Einrichtung (Seite „Vertrag widerrufen“, Auswahl „Webshop (Einrichtung)“), kommt die Mitteilung T09 mit Fristende und Preis; es wird nichts automatisch geändert. Du prüfst, erstattest in Stripe innerhalb von 14 Tagen (gegebenenfalls abzüglich Wertersatz für den begonnenen Einbau) und schaltest den Webshop ab. Mit dem Widerruf endet der Webshop. + +Das Stripe-Konto des Händlers bleibt in jedem Fall beim Händler; wir rühren es nicht an. diff --git a/docs/funktionen/shop-livegang.md b/docs/funktionen/shop-livegang.md new file mode 100644 index 0000000..0304f83 --- /dev/null +++ b/docs/funktionen/shop-livegang.md @@ -0,0 +1,91 @@ +# Webshop – Livegang + +Reihenfolge für den Inhaber. Jeder Schritt braucht seine Freigabe; nichts davon hat Claude gegen Produktion oder das +Live-Stripe-Konto ausgeführt. Code: Portal `feature/webshop` (Worktree `dev-webshop`), WOMS `feature/webshop` +(Worktree `ticket.webklar.com/dev-webshop`). Beide Zweige bauen auf `feature/terminplaner-tp2` auf. + +## 0. Voraussetzung + +- **TP2 (Terminplaner-Verkauf) ist live.** Der Webshop nutzt das Funktionen-Register aus TP2. Erst TP2 nach `main` + (Portal) bzw. `test` (WOMS), dann diesen Zweig darauf mergen (`git merge origin/main` im Worktree, Tests laufen lassen). +- Der Zweig `feature/oeffnungszeiten` baut ebenfalls auf TP2 auf. Wer zuerst live geht, ist egal; der zweite merged den + ersten nach (Konflikte erwartbar in `server/config.js` features, `public/app.js`, `public/funktionen.js`, + `test/mailTemplates.test.js` (Vorlagen-IDs), `server/index.js`). + +## 1. Stripe-Dashboard (WEBklar-Konto, live) + +1. **Connect aktivieren**: Einstellungen → Connect → Plattformprofil ausfüllen: „Plattform/Software, deren Nutzer an + eigene Kunden verkaufen“, Geldfluss **Direct Charges**, **keine** Plattformgebühr, Konten mit eigenem vollen + Stripe-Dashboard (Standard-Konten). Danach unter Connect → Branding Name „WEBklar“, Icon und Farbe für das Onboarding. +2. **Connect-Webhook** anlegen: Entwickler → Webhooks → Endpunkt hinzufügen, **„Ereignisse in verbundenen Konten“**, + URL `https://project.webklar.com/api/shop/stripe-webhook`, Ereignisse: + `account.updated`, `checkout.session.completed`, `checkout.session.async_payment_succeeded`, + `checkout.session.async_payment_failed`, `checkout.session.expired`, `charge.refunded`. + Das Signatur-Geheimnis (`whsec_…`) kommt in Schritt 2 in die `.env`. +3. Nichts weiter: Händler-Konten legt das Portal selbst an, wenn ein Händler „Stripe-Konto einrichten“ klickt. + +## 2. Server-Konfiguration + +In `/home/webklar/apps/.env`: +``` +SHOP_ENABLED=pilot +SHOP_PILOT_EMAILS=kenso.gri@gmail.com +STRIPE_CONNECT_WEBHOOK_SECRET=whsec_… +``` +In `/home/webklar/apps/docker-compose.yml` beim Dienst `kundenbereich-server` die drei Variablen durchreichen +(`SHOP_ENABLED=${SHOP_ENABLED:-off}`, `SHOP_PILOT_EMAILS=${SHOP_PILOT_EMAILS:-}`, +`STRIPE_CONNECT_WEBHOOK_SECRET=${STRIPE_CONNECT_WEBHOOK_SECRET:-}`) und danach +`docker compose -f /home/webklar/apps/docker-compose.yml up -d --no-deps kundenbereich-server` +(ein `docker restart` liest die `.env` nicht neu). Artikelbilder liegen unter `build/upload-data/shop/` (gitignored, +überleben Deploys wie die News-Fotos). + +## 3. Datenbank und Stripe-Preis (vor dem Deploy) + +```bash +cd /home/webklar/apps/kundenbereich.webklar.com/build +docker exec -w /app webklar-kundenbereich-server node scripts/shop-setup.mjs --check +docker exec -w /app webklar-kundenbereich-server node scripts/shop-setup.mjs +docker exec -w /app webklar-kundenbereich-server node scripts/stripe-products.mjs --pruefen +docker exec -w /app webklar-kundenbereich-server node scripts/stripe-products.mjs --anlegen # legt shopSetup 299 € an +``` +`shop-setup.mjs` legt fünf Collections an (`shopConfig`, `shopProducts`, `shopOrders`, `shopWiderrufe`, +`shopRechtstexteArchiv`). Die Käufer-Mails laufen über `termineOutbox`; die Collection kommt aus dem TP2-Livegang +(`scripts/termine-setup.mjs`). Die Skripte müssen aus dem neuen Code laufen (nach Schritt 4 oder vorher aus dem Worktree +mit derselben Umgebung). + +## 4. Deploy + +- Portal: `feature/webshop` → `main` pushen (Webhook deployt). +- WOMS: `feature/webshop` → `test` pushen; danach im WOMS „Rechtliches“: `scripts/setup-legal.mjs` laufen lassen (neue + Karten „Leistungsbeschreibung Webshop“, „AVV Webshop“) und die Fassungen 2026-10-05 hochladen + (`public/rechtliches/leistungsbeschreibung-webshop-2026-10-05.pdf`, `avv-webshop-2026-10-05.pdf`). +- Prüfen: `https://project.webklar.com/rechtliches/leistungsbeschreibung-webshop.html` und + `…/avv-webshop.html` antworten 200; `https://project.webklar.com/embed/shop.js` antwortet 200. + +## 5. Pilot mit dem Testkonto + +1. Im Portal als Testkunde: Funktionen → Webshop → Anfrage stellen (Projekt `test-website`). +2. WOMS → Kunde → Funktionen → Webshop → „Anfrage annehmen“ (Mail K24 kommt an). +3. Einrichtung: entweder bestellen (299 €, danach in Stripe erstatten) oder im WOMS „Aktivieren (ohne Zahlung)“ + (Pilot-Adressen brauchen keine Textform). +4. Einbau nach `docs/funktionen/shop-einbau.md` auf der Test-Website, Ticket schließen → Freischaltung, Mail K16. +5. Portal → Webshop → Übersicht → „Stripe-Konto einrichten“ (echtes Onboarding mit den Daten von WEBklar oder einem + Testbetrieb; Stripe prüft Identität und Bankkonto). +6. Einstellungen ausfüllen (Steuerart wählen!), einen Artikel für 1,00 € anlegen, Rechtstexte: Muster einsetzen, + Platzhalter ersetzen, Impressum-/Datenschutz-URL, bestätigen, „Shop öffnen“. +7. Testkauf auf der Website mit echter Karte → Mails G09 (Käufer) und K25 (Händler) prüfen → im Portal „Versendet“ + (G10) → „Erstatten“ (G13) → im Stripe-Dashboard des Händlers sichtbar. +8. Widerruf über den Fußzeilen-Link testen (G12 mit Datum und Uhrzeit, K26 an den Händler). + +## 6. Freigabe für alle + +- Kanzlei-Paket `docs/rechtliches/webshop-kanzlei-paket.md` an die Kanzlei, Befund einarbeiten (neue Fassung, falls + sich Texte ändern; ausgelieferte Fassungen werden nie überschrieben). +- Checkliste in Abschnitt 7 des Kanzlei-Pakets abhaken. +- `SHOP_ENABLED=on`, Compose neu anwenden (wie Schritt 2). + +## Zurück + +`SHOP_ENABLED=off` blendet die Kachel aus und stoppt neue Bestellungen. Bestehende Shops (Zustand aktiv oder +abgeschaltet) behalten ihre Widerrufsfunktion, ihre Fristen und die Löschjobs laufen weiter. Einzelne Shops schaltet das +Team im WOMS ab (Funktionen → Webshop → „Abschalten“), die Daten werden 90 Tage danach gelöscht. diff --git a/docs/funktionen/shop-muster-rechtstexte.md b/docs/funktionen/shop-muster-rechtstexte.md new file mode 100644 index 0000000..e34dd66 --- /dev/null +++ b/docs/funktionen/shop-muster-rechtstexte.md @@ -0,0 +1,166 @@ +# Shop: Muster-Rechtstexte (Widerrufsbelehrung, Widerrufsformular, AGB) + +**Muster – keine Rechtsberatung.** Diese Texte sind Muster, die das Kundenportal im Webshop vorbefüllt (`server/services/shop/rechtstexte.js`, dort mit den Angaben aus den Shop-Einstellungen). Der Händler prüft sie, passt sie an und bestätigt sie selbst; WEBklar berät nicht rechtlich. Diese Datei enthält dieselben Texte mit Platzhaltern in eckigen Klammern zur Prüfung durch eine Kanzlei (Plan 2026-10-05, Task C5; Offene Punkte 5 und 9 der Recherche). + +Stand: 2026-10-05. + +## Quellen und Fundstellen + +- Widerrufsbelehrung: Anlage 1 zu Art. 246a § 1 Abs. 2 S. 2 EGBGB, Fassung 2026 mit dem Baustein zur Widerrufsfunktion (https://www.gesetze-im-internet.de/bgbeg/BJNR006049896.html). Ein richtig ausgefülltes Muster erfüllt die Informationspflicht (Art. 246a § 1 Abs. 2 S. 3 EGBGB). +- Elektronische Widerrufsfunktion: § 356a BGB (seit 19.06.2026), Hinweispflicht in der Belehrung nach Art. 246a § 1 Abs. 2 S. 1 Nr. 1 EGBGB. +- Widerrufsformular: Anlage 2 zu Art. 246a § 1 Abs. 2 S. 1 Nr. 1 EGBGB. +- Ausschluss und vorzeitiges Erlöschen: § 312g Abs. 2 Nr. 1, 2, 3 BGB; Information nach Art. 246a § 1 Abs. 3 EGBGB. +- Gewährleistungshinweis: Art. 246a § 1 Abs. 1 S. 1 Nr. 11 EGBGB i. V. m. Durchführungsverordnung (EU) 2025/1960 Anhang I (seit 27.09.2026). +- Streitbeilegung: § 36 VSBG. Die EU-Plattform zur Online-Streitbeilegung wurde am 20.07.2025 eingestellt; es gibt keinen Link darauf. +- Button und Vertragsschluss: § 312j Abs. 3 und 4 BGB, § 312i BGB. Recherche: `/root/reports/webshop-2026-10-05/recherche-recht-stripe.md` (B.1 bis B.10). + +## Hinweise zur Prüfung (nicht Teil der Texte) + +- Fristbeginn: Die Belehrung enthält beide gesetzlichen Alternativen (eine Ware bzw. die Waren; mehrere Waren einer Bestellung, getrennt geliefert: die letzte Ware), weil der Warenkorb beides erzeugen kann. Teilsendungen und regelmäßige Lieferung sind nicht vorgesehen. +- Rücksendekosten: Standard ist „Käufer trägt“. Variante „Händler trägt“ steht unten. +- Abholung: Bietet der Shop Abholung an, kommt der Satz „Haben Sie die Waren bei uns abgeholt …“ hinzu. +- Ausschlussgründe: Der letzte Absatz der Belehrung nennt § 312g Abs. 2 Nr. 1, 2 und 3 BGB in allgemeiner Form. Ob und für welche Waren er zutrifft, entscheidet der Händler; Einordnung von Lieferung und Abholung von Speisen (§ 312g Abs. 2 Nr. 9, § 312 Abs. 2 Nr. 8 BGB) ist offen (Recherche, Offene Punkte 6). +- Gewährleistungshinweis: amtliche Grafik und wörtlicher Text aus Anhang I eingesetzt; offen bleibt nur der Abgleich der Grafikdatei mit der Originaldatei der Kommission. +- Widerrufsfunktion: Erreichbarkeit ohne Login bei Gastbestellungen, Identifikation der Bestellung und Zeitstempel mit der Kanzlei abstimmen (Recherche, Offene Punkte 7). + +## Widerrufsbelehrung + +Widerrufsbelehrung + +Widerrufsrecht + +Sie haben das Recht, binnen vierzehn Tagen ohne Angabe von Gründen diesen Vertrag zu widerrufen. + +Die Widerrufsfrist beträgt vierzehn Tage ab dem Tag, an dem Sie oder ein von Ihnen benannter Dritter, der nicht der Beförderer ist, die Waren in Besitz genommen haben bzw. hat. + +Haben Sie mehrere Waren im Rahmen einer einheitlichen Bestellung bestellt und werden diese getrennt geliefert, beträgt die Widerrufsfrist vierzehn Tage ab dem Tag, an dem Sie oder ein von Ihnen benannter Dritter, der nicht der Beförderer ist, die letzte Ware in Besitz genommen haben bzw. hat. + +Um Ihr Widerrufsrecht auszuüben, müssen Sie uns ([Name des Händlers], [Straße Nr.], [PLZ] [Ort], E-Mail: [E-Mail-Adresse]) mittels einer eindeutigen Erklärung (z. B. ein mit der Post versandter Brief oder eine E-Mail) über Ihren Entschluss, diesen Vertrag zu widerrufen, informieren. Sie können dafür das beigefügte Muster-Widerrufsformular verwenden, das jedoch nicht vorgeschrieben ist. + +Sie können Ihr Widerrufsrecht auch online unter [Internetadresse der Widerrufsfunktion im Shop] ausüben. Wenn Sie diese Online-Funktion nutzen, übermitteln wir Ihnen auf einem dauerhaften Datenträger (z. B. durch eine E-Mail) unverzüglich eine Eingangsbestätigung mit Informationen zum Inhalt der Widerrufserklärung sowie dem Datum und der Uhrzeit ihres Eingangs. + +Zur Wahrung der Widerrufsfrist reicht es aus, dass Sie die Mitteilung über die Ausübung des Widerrufsrechts vor Ablauf der Widerrufsfrist absenden. + +Folgen des Widerrufs + +Wenn Sie diesen Vertrag widerrufen, haben wir Ihnen alle Zahlungen, die wir von Ihnen erhalten haben, einschließlich der Lieferkosten (mit Ausnahme der zusätzlichen Kosten, die sich daraus ergeben, dass Sie eine andere Art der Lieferung als die von uns angebotene, günstigste Standardlieferung gewählt haben), unverzüglich und spätestens binnen vierzehn Tagen ab dem Tag zurückzuzahlen, an dem die Mitteilung über Ihren Widerruf dieses Vertrags bei uns eingegangen ist. Für diese Rückzahlung verwenden wir dasselbe Zahlungsmittel, das Sie bei der ursprünglichen Transaktion eingesetzt haben, es sei denn, mit Ihnen wurde ausdrücklich etwas anderes vereinbart; in keinem Fall werden Ihnen wegen dieser Rückzahlung Entgelte berechnet. + +Wir können die Rückzahlung verweigern, bis wir die Waren wieder zurückerhalten haben oder bis Sie den Nachweis erbracht haben, dass Sie die Waren zurückgesandt haben, je nachdem, welches der frühere Zeitpunkt ist. + +Sie haben die Waren unverzüglich und in jedem Fall spätestens binnen vierzehn Tagen ab dem Tag, an dem Sie uns über den Widerruf dieses Vertrags unterrichten, an uns ([Name des Händlers], [Straße Nr.], [PLZ] [Ort]) zurückzusenden oder zu übergeben. Die Frist ist gewahrt, wenn Sie die Waren vor Ablauf der Frist von vierzehn Tagen absenden. + +Sie tragen die unmittelbaren Kosten der Rücksendung der Waren. + +Sie müssen für einen etwaigen Wertverlust der Waren nur aufkommen, wenn dieser Wertverlust auf einen zur Prüfung der Beschaffenheit, Eigenschaften und Funktionsweise der Waren nicht notwendigen Umgang mit ihnen zurückzuführen ist. + +Ausschluss bzw. vorzeitiges Erlöschen des Widerrufsrechts + +Das Widerrufsrecht besteht nicht bei Verträgen zur Lieferung von Waren, die nicht vorgefertigt sind und für deren Herstellung eine individuelle Auswahl oder Bestimmung durch den Verbraucher maßgeblich ist oder die eindeutig auf die persönlichen Bedürfnisse des Verbrauchers zugeschnitten sind (§ 312g Abs. 2 Nr. 1 BGB), z. B. Waren mit Wunschaufdruck oder Maßanfertigungen. +Das Widerrufsrecht besteht nicht bei Verträgen zur Lieferung von Waren, die schnell verderben können oder deren Verfallsdatum schnell überschritten würde (§ 312g Abs. 2 Nr. 2 BGB), z. B. frische Lebensmittel. +Das Widerrufsrecht erlischt vorzeitig bei Verträgen zur Lieferung versiegelter Waren, die aus Gründen des Gesundheitsschutzes oder der Hygiene nicht zur Rückgabe geeignet sind, wenn ihre Versiegelung nach der Lieferung entfernt wurde (§ 312g Abs. 2 Nr. 3 BGB). + +Ende der Widerrufsbelehrung + +### Variante: Händler trägt die Rücksendekosten + +Statt „Sie tragen die unmittelbaren Kosten der Rücksendung der Waren.“ steht: + +Wir tragen die Kosten der Rücksendung der Waren. + +### Variante: Abholung angeboten + +Nach dem Absatz zur Rücksendung folgt zusätzlich: + +Haben Sie die Waren bei uns abgeholt, übergeben Sie diese zur Rückgabe an unserer Anschrift: [Abholadresse]. + +## Widerrufsformular + +Muster-Widerrufsformular + +(Wenn Sie den Vertrag widerrufen wollen, dann füllen Sie bitte dieses Formular aus und senden Sie es zurück.) + +An [Name des Händlers], [Straße Nr.], [PLZ] [Ort], E-Mail: [E-Mail-Adresse]: + +Hiermit widerrufe(n) ich/wir (*) den von mir/uns (*) abgeschlossenen Vertrag über den Kauf der folgenden Waren (*)/die Erbringung der folgenden Dienstleistung (*) + +– Bestellt am (*)/erhalten am (*) +– Name des/der Verbraucher(s) +– Anschrift des/der Verbraucher(s) +– Unterschrift des/der Verbraucher(s) (nur bei Mitteilung auf Papier) +– Datum + +(*) Unzutreffendes streichen. + +## Allgemeine Geschäftsbedingungen + +Die Kopfzeile „Muster – bitte prüfen und anpassen“ steht im vorbefüllten Text und wird vom Händler nach der Prüfung entfernt. Die Zahlungsarten, Länder und Abholung werden aus den Shop-Einstellungen eingesetzt; die Platzhalter unten zeigen den Standardfall. + +Muster – bitte prüfen und anpassen + +Allgemeine Geschäftsbedingungen + +§ 1 Geltung und Anbieter + +Diese Allgemeinen Geschäftsbedingungen gelten für alle Bestellungen von Verbrauchern in unserem Online-Shop. Anbieter und Vertragspartner ist [Name des Händlers], [Straße Nr.], [PLZ] [Ort], E-Mail: [E-Mail-Adresse]. + +§ 2 Vertragsschluss + +Die Darstellung der Waren im Shop ist kein verbindliches Angebot. Mit dem Klick auf die Schaltfläche „Zahlungspflichtig bestellen“ geben Sie eine verbindliche Bestellung ab. Den Eingang Ihrer Bestellung bestätigen wir Ihnen sofort auf einer Bestätigungsseite im Shop. Bei den Zahlungsarten Kreditkarte, Apple Pay, Google Pay, Klarna erhalten Sie die Bestellbestätigung per E-Mail unmittelbar nach erfolgreicher Zahlung; mit dieser E-Mail nehmen wir Ihre Bestellung an und der Vertrag kommt zustande. Bei SEPA-Lastschrift senden wir Ihnen zunächst eine E-Mail über den Eingang Ihrer Bestellung; der Vertrag kommt in diesem Fall erst zustande, wenn wir Ihnen nach Eingang der Zahlung die Bestellbestätigung per E-Mail senden. Bestellungen, die nicht bezahlt werden, werden nach Ablauf der Zahlungsfrist verworfen. + +§ 3 Preise und Versandkosten + +Es gelten die zum Zeitpunkt der Bestellung im Shop angegebenen Preise in Euro. Alle Preise enthalten die gesetzliche Umsatzsteuer. Versandkosten und etwaige weitere Kosten werden vor Abgabe der Bestellung gesondert ausgewiesen und richten sich nach dem Zielland. Einzelheiten finden Sie unter „Versand & Zahlung“. + +§ 4 Zahlung + +Die Zahlung erfolgt über den Zahlungsdienstleister Stripe. Es stehen folgende Zahlungsarten zur Verfügung: Kreditkarte, Apple Pay, Google Pay, Klarna, SEPA-Lastschrift. Bei SEPA-Lastschrift versenden wir die Ware erst nach Eingang der Zahlung. + +§ 5 Lieferung und Abholung + +Wir liefern in folgende Länder: Deutschland. Die Lieferzeit beträgt [Lieferzeit], gerechnet ab Zahlungseingang. Ist eine Ware nicht verfügbar, informieren wir Sie unverzüglich. + +§ 6 Eigentumsvorbehalt + +Die Waren bleiben bis zur vollständigen Bezahlung unser Eigentum. + +§ 7 Widerrufsrecht + +Verbrauchern steht ein gesetzliches Widerrufsrecht zu. Einzelheiten ergeben sich aus der Widerrufsbelehrung, die Sie unter „Widerrufsbelehrung“ im Shop finden. + +§ 8 Gewährleistung + +Es gelten die gesetzlichen Mängelhaftungsrechte. Der Hinweis auf das gesetzliche Gewährleistungsrecht steht vor Abgabe der Bestellung im Shop. + +§ 9 Technische Schritte, Vertragstext und Sprache + +Zum Vertragsschluss führen folgende Schritte: Sie legen die gewünschten Waren in den Warenkorb, prüfen die Bestellübersicht mit allen Angaben und klicken auf „Zahlungspflichtig bestellen“. Danach zahlen Sie bei unserem Zahlungsdienstleister Stripe. Der Vertrag kommt nach § 2 mit unserer Bestellbestätigung per E-Mail zustande. Vor dem Klick auf „Zahlungspflichtig bestellen“ können Sie Eingabefehler erkennen und berichtigen: Im Warenkorb ändern Sie Mengen oder entfernen Waren, aus der Bestellübersicht gelangen Sie über „Zurück zum Warenkorb“ dorthin zurück, und Ihre Angaben zu Adresse und Zahlung prüfen und korrigieren Sie bei Stripe, bevor Sie die Zahlung abschließen. Wir speichern den Vertragstext nach Vertragsschluss nicht für Sie abrufbar. Ihre Bestelldaten, diese Allgemeinen Geschäftsbedingungen und die Widerrufsbelehrung erhalten Sie mit der Bestellbestätigung per E-Mail; bitte bewahren Sie diese E-Mail auf. Die Vertragssprache ist Deutsch. Wir unterwerfen uns keinen besonderen Verhaltenskodizes. + +§ 10 Streitbeilegung + +[Name des Händlers] ist nicht bereit und nicht verpflichtet, an einem Streitbeilegungsverfahren vor einer Verbraucherschlichtungsstelle teilzunehmen (§ 36 VSBG). + +Zu § 2: Der Satz zur sofortigen Bestellbestätigung nennt nur die Zahlungsarten Karte/Apple Pay/Google Pay/Klarna; der SEPA-Satz erscheint nur, wenn SEPA-Lastschrift aktiv ist. Ohne SEPA entfällt er, bei ausschließlich SEPA entfällt der Satz davor. Der Ablauf im Shop: Bestätigungsseite sofort nach der Bestellung; bei Karte/Klarna folgt die Bestellbestätigung (G09, Vertragsschluss) unmittelbar nach der Zahlung; bei SEPA zuerst die Eingangsmail (G15), die Bestellbestätigung (G09) erst nach Zahlungseingang. + +Zu § 9: enthält die Angaben nach Art. 246c EGBGB (technische Schritte, Eingabefehler erkennen und berichtigen, Speicherung des Vertragstextes, Sprache, Verhaltenskodizes). + +Bei Kleinunternehmern steht in § 3 statt „Alle Preise enthalten die gesetzliche Umsatzsteuer.“ der Satz „Gemäß § 19 UStG wird keine Umsatzsteuer berechnet und ausgewiesen.“ + +## Hinweis auf das gesetzliche Gewährleistungsrecht (Kasse und Bestellbestätigung) + +Amtliche Grafik nach Durchführungsverordnung (EU) 2025/1960 Anhang I (ABl. L vom 2.10.2025), nicht editierbar, online farbig: `public/img/gewaehrleistung-eu.webp` (aus dem Amtsblatt ausgeschnitten; vor Livegang mit der Originaldatei der Kommission abgleichen, Kanzlei-Frage). Wörtlicher Text der Grafik (Alternativtext, Bestellbestätigung): + +GESETZLICHE GEWÄHRLEISTUNG + +Mindestens zwei Jahre gesetzliche Gewährleistung der Vertragsmäßigkeit für Waren, die in der Europäischen Union verkauft werden. + +Verbraucherinnen und Verbraucher können ihre Rechte im Rahmen des gesetzlichen Gewährleistungsrechts geltend machen, z. B. wenn die Waren nicht der Beschreibung entsprechen, nicht bestimmungsgemäß funktionieren. + +Verkäufer haften für jede Vertragswidrigkeit, die zum Zeitpunkt der Lieferung der Waren bestand und innerhalb des Zeitraums der gesetzlichen Gewährleistung erkennbar wird. Verkäufer müssen in solchen Fällen Folgendes anbieten: kostenlose Nachbesserung oder kostenlose Ersatzlieferung, in bestimmten Fällen eine Preisminderung oder eine vollständige Erstattung des Kaufpreises. + +In einigen Ländern gilt ein längerer Zeitraum für die gesetzliche Gewährleistung. Für gebrauchte Waren kann ein kürzerer Zeitraum gelten, jedoch nicht weniger als ein Jahr. + +Für weitere Informationen zu Ihren Rechten in einem bestimmten Land scannen Sie den nachstehenden QR-Code oder fragen Sie den Verkäufer. europa.eu/youreurope/garantien + +Was ist zu tun, wenn Sie vertragswidrige Waren erhalten? 1. Melden Sie dem Verkäufer das Problem so bald wie möglich. 2. Legen Sie einen Kaufnachweis vor, z. B. die Quittung, Rechnung oder einen Kontoauszug. + +Verkäufer und Hersteller können auch gewerbliche Garantien gewähren, die unabhängig von der gesetzlichen Gewährleistung gelten. Diese GARAN-Kennzeichnung zeigt beispielsweise, dass der Hersteller eine gewerbliche Haltbarkeitsgarantie ohne zusätzliche Kosten gewährt, die die gesamte Ware abdeckt. diff --git a/docs/funktionen/termine-einbau.md b/docs/funktionen/termine-einbau.md index 4703ae6..b2fcdf8 100644 --- a/docs/funktionen/termine-einbau.md +++ b/docs/funktionen/termine-einbau.md @@ -115,7 +115,7 @@ Bricht das Netz mitten in der Strecke ab, zeigt das Widget „Gerade nicht errei 2. Person wählen („Egal“ zuerst). Nur wenn mehrere Personen infrage kommen und der Betrieb die Wahl erlaubt; bei Plätzen entfällt der Schritt. 3. Tag (Monatsraster, nur Tage mit freien Zeiten sind wählbar) und Uhrzeit. Alle Daten und Uhrzeiten kommen vom Server in Berliner Zeit. Das Widget rechnet nie mit dem Datum oder der Zeitzone des Browsers. 4. Angaben: Name, E-Mail, Telefon, Nachricht und Zusatzfelder nach den Einstellungen des Betriebs. -5. Zusammenfassung mit Beschreibung der Leistung, Stornobedingungen (daneben der Link „AGB des Betriebs“, wenn im Portal eine https-Adresse hinterlegt ist), Widerrufsbelehrung (aufklappbar, bei Verbraucher-Betrieben), Datenschutz-Hinweis, den Haken (Storno, vorzeitiger Beginn, Erinnerung) und dem Knopf („Zahlungspflichtig buchen“, „Termin buchen“ oder „Termin anfragen“, kommt vom Server). +5. Zusammenfassung mit Beschreibung der Leistung, Stornobedingungen (nur bei einer Leistung mit Preis; daneben der Link „AGB des Betriebs“, wenn im Portal eine https-Adresse hinterlegt ist), Widerrufsbelehrung (aufklappbar, bei Verbraucher-Betrieben), Datenschutz-Hinweis, den Haken (Storno nur bei einer Leistung mit Preis, vorzeitiger Beginn, Erinnerung) und dem Knopf („Zahlungspflichtig buchen“, „Termin buchen“ oder „Termin anfragen“, kommt vom Server). 6. „Fast geschafft“: Der Gast bestätigt den Termin über den Link in der E-Mail. Die Reservierung gilt 30 Minuten. Im Fuß stehen „Vertrag widerrufen“ als zweiter Knopf (kein bloßer Textlink; für Verbraucher bei jeder Leistung, ob sofort buchbar oder als Anfrage, weil auch ein angenommener Anfrage-Vertrag widerrufen werden kann), „Datenschutz“ (Link auf die Datenschutzerklärung des Betriebs) und klein „Terminbuchung über WEBklar“. Im Modus `knopf` steht der Fuß nur im geöffneten Dialog. Damit die Widerrufsfunktion (§ 356a BGB) trotzdem dauerhaft auffindbar ist, lädt das Widget dort schon beim Laden der Seite die Konfiguration (ein einziger Abruf, sonst nichts) und setzt, wenn der Betrieb ein Widerrufsrecht hat, den Knopf „Vertrag widerrufen“ als zweiten Knopf neben den Buchungsknopf. Er öffnet den Dialog gleich beim Widerrufsformular. Anrede (Sie oder Du) und Betriebsfarbe kommen aus den Einstellungen. Die rechtlich vorgeschriebenen Texte (Haken, Belehrung) stehen immer in der Sie-Form. diff --git a/docs/rechtliches/archiv/README.md b/docs/rechtliches/archiv/README.md index 6747a98..90395f9 100644 --- a/docs/rechtliches/archiv/README.md +++ b/docs/rechtliches/archiv/README.md @@ -31,4 +31,4 @@ Die Texte, die ein Gast beim Buchen sieht (Hinweis am Formular, Texte der Haken, „Gültig ab“ einer neuen Fassung wird beim Livegang eingetragen; ihr Zeitraum endet mit dem „gültig ab“ der nächsten. -Änderungen der Fassung 2026-10-05 gegenüber 2026-10-02 (nur diese beiden Stellen): Der Haken für die Erinnerung endet mit dem Satz „Diese Einwilligung kann ich jederzeit widerrufen.“ (Art. 7 Abs. 3 DSGVO); der Datenschutz-Absatz sagt bei der Abschaltung: „Wird die Online-Terminbuchung abgeschaltet, zum Beispiel weil unser Vertrag mit WEBklar endet, werden alle Termindaten 30 Tage nach dem Abschalten gelöscht.“ Buchungen, die vor dem Livegang der neuen Fassung entstanden sind, behalten im Nachweis die Fassung 2026-10-02. +Änderungen der Fassung 2026-10-05 gegenüber 2026-10-02 (Textänderungen: nur diese beiden Stellen): Der Haken für die Erinnerung endet mit dem Satz „Diese Einwilligung kann ich jederzeit widerrufen.“ (Art. 7 Abs. 3 DSGVO); der Datenschutz-Absatz sagt bei der Abschaltung: „Wird die Online-Terminbuchung abgeschaltet, zum Beispiel weil unser Vertrag mit WEBklar endet, werden alle Termindaten 30 Tage nach dem Abschalten gelöscht.“ Außerdem ändert sich die Regel zum Haken „Stornobedingungen“, nicht sein Text: Pflicht ist er nur noch bei einer Leistung mit Preis, bei kostenlosen Leistungen fragt der Terminplaner nicht danach (die Fassung 2026-10-02 verlangte ihn bei jeder Leistung, sobald der Betrieb Stornobedingungen hat). Buchungen, die vor dem Livegang der neuen Fassung entstanden sind, behalten im Nachweis die Fassung 2026-10-02. diff --git a/docs/rechtliches/archiv/termine-gaestetexte-2026-10-05.md b/docs/rechtliches/archiv/termine-gaestetexte-2026-10-05.md index 2f58b8b..a239821 100644 --- a/docs/rechtliches/archiv/termine-gaestetexte-2026-10-05.md +++ b/docs/rechtliches/archiv/termine-gaestetexte-2026-10-05.md @@ -26,7 +26,7 @@ Die Haken stehen immer in der Sie-Form, auch bei Betrieben, die ihre Gäste duze > Ich verlange ausdrücklich, dass {{Name des Betriebs}} mit der Leistung vor Ablauf der Widerrufsfrist beginnt. Mir ist bekannt, dass mein Widerrufsrecht mit der vollständigen Erbringung der Leistung erlischt (§ 356 Abs. 5 Nr. 2 BGB). Widerrufe ich vor der vollständigen Erbringung, schulde ich einen angemessenen Betrag für die bis dahin erbrachte Leistung (§ 357a Abs. 2 BGB). -### Stornobedingungen (Pflicht, wenn der Betrieb Stornobedingungen hat; der Text der Bedingungen steht beim Betrieb und trägt eine eigene Version) +### Stornobedingungen (Pflicht bei einer Leistung mit Preis, wenn der Betrieb Stornobedingungen hat; bei kostenlosen Leistungen fragt der Terminplaner nicht danach; der Text der Bedingungen steht beim Betrieb und trägt eine eigene Version) > Ich habe die Stornobedingungen gelesen. diff --git a/docs/rechtliches/avv-terminplaner.md b/docs/rechtliches/avv-terminplaner.md index c7f00f5..90f70f9 100644 --- a/docs/rechtliches/avv-terminplaner.md +++ b/docs/rechtliches/avv-terminplaner.md @@ -8,7 +8,7 @@ zwischen dem Kunden als Verantwortlichem („Kunde“) und der WEBklar GbR, Am S (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. -(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. +(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. (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. @@ -72,7 +72,7 @@ WEBklar setzt nur Personen ein, die zur Vertraulichkeit verpflichtet sind oder e (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. -(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). +(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). (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. @@ -94,7 +94,7 @@ WEBklar setzt nur Personen ein, die zur Vertraulichkeit verpflichtet sind oder e (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. -(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. +(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. (7) Entwürfe aus dem Fragebogen, aus denen keine Bestellung geworden ist, löscht WEBklar 90 Tage nach der letzten Änderung. @@ -138,7 +138,7 @@ WEBklar stellt dem Kunden alle Informationen zur Verfügung, die zum Nachweis de | Missbrauchsschutz | 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. | | Datenminimierung | 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. | | Trennung | 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. | -| Löschung | 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. | +| Löschung | 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. | | Protokolle | 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). | | Datensicherung | Tägliche Vollsicherung des Servers durch Hetzner; verfügbar sind die letzten sieben Tagessicherungen (§ 3 Abs. 5 der Abo-Bedingungen). | | Rechenzentrum | 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. | diff --git a/docs/rechtliches/avv-webshop.md b/docs/rechtliches/avv-webshop.md new file mode 100644 index 0000000..3d21dac --- /dev/null +++ b/docs/rechtliches/avv-webshop.md @@ -0,0 +1,145 @@ +# Auftragsverarbeitungsvertrag Webshop (Art. 28 DSGVO) + +**Fassung 2026-10-05** + +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“). + +## Vorbemerkung: Abschluss und Verhältnis zu anderen Verträgen + +(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. + +(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. + +(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. + +(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). + +## 1. Gegenstand und Dauer + +(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. + +(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. + +## 2. Art und Zweck der Verarbeitung + +(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. + +(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). + +(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. + +## 3. Datenarten und Betroffene + +(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. **Kartendaten und Kontodaten (IBAN) erhält und speichert WEBklar nicht;** die gibt der Käufer nur bei Stripe ein. **Eine Telefonnummer des Käufers erhebt und speichert WEBklar nicht:** 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. + +(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. + +(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). + +(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 **Hashwert der IP-Adresse („IP-Hash“)**: 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). + +(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. + +(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. + +## 4. Weisungen + +(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. + +(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. + +(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. + +## 5. Vertraulichkeit + +WEBklar setzt nur Personen ein, die zur Vertraulichkeit verpflichtet sind oder einer gesetzlichen Verschwiegenheitspflicht unterliegen. + +## 6. Technische und organisatorische Maßnahmen + +(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. + +(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. + +## 7. Unterauftragsverarbeiter + +(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. + +(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). + +(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). + +(4) **Stripe ist kein Unterauftragsverarbeiter von WEBklar.** 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. + +## 8. Unterstützung des Kunden, Betroffenenrechte + +(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. + +(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. + +(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. + +(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. + +(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). + +(6) Die Käufer informiert der Kunde (Art. 13 DSGVO; Nummer 11 Abs. 2). + +## 9. Löschung und Rückgabe + +(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. + +(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. + +(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. + +(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. + +(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. + +(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. + +(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. + +(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. + +## 10. Nachweise und Kontrollen + +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. + +## 11. Pflichten des Kunden + +(1) Der Kunde ist Verantwortlicher für die Verarbeitung der Daten seiner Käufer im Webshop. + +(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. + +(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). + +(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. + +(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). + +## 12. Haftung, Änderungen, Schlussbestimmungen + +(1) Gegenüber betroffenen Personen gilt Art. 82 DSGVO. Für die Haftung zwischen Kunde und WEBklar gilt § 13 der Abo-Bedingungen. + +(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. + +(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. + +(4) Im Übrigen gilt § 16 der Abo-Bedingungen. + +## Anlage: Technische und organisatorische Maßnahmen des Webshops (Art. 32 DSGVO) + +| Bereich | Maßnahmen | +|---|---| +| Übertragung | 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. | +| Speicher im Browser | 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. | +| Zugriffskontrolle | 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. | +| Missbrauchsschutz | 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). | +| Datenminimierung | 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. | +| Trennung | 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. | +| Löschung | 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). | +| Protokolle | 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). | +| Datensicherung | Tägliche Vollsicherung des Servers durch Hetzner; verfügbar sind die letzten sieben Tagessicherungen (§ 3 Abs. 5 der Abo-Bedingungen). | +| Rechenzentrum | 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. | +| Organisation | Verpflichtung aller Beteiligten auf Vertraulichkeit, Ablauf für Datenschutzverletzungen (Nummer 8 Abs. 3), Überprüfung der Maßnahmen mindestens einmal im Jahr. | diff --git a/docs/rechtliches/leistungsbeschreibung-terminplaner.md b/docs/rechtliches/leistungsbeschreibung-terminplaner.md index e47671c..08ecceb 100644 --- a/docs/rechtliches/leistungsbeschreibung-terminplaner.md +++ b/docs/rechtliches/leistungsbeschreibung-terminplaner.md @@ -90,7 +90,7 @@ (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. -(3) WEBklar löscht alle Termindaten und die Einstellungen 30 Tage nach dem Abschalten. +(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). (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). diff --git a/docs/rechtliches/leistungsbeschreibung-webshop.md b/docs/rechtliches/leistungsbeschreibung-webshop.md new file mode 100644 index 0000000..a2fa77d --- /dev/null +++ b/docs/rechtliches/leistungsbeschreibung-webshop.md @@ -0,0 +1,181 @@ +# Leistungsbeschreibung Webshop + +**Fassung 2026-10-05** · Zusatzbedingungen zum Vertrag über den Betrieb der Website + +## 1. Geltung + +(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. + +(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). + +(3) Im Übrigen gelten die Abo-Bedingungen. Bei Widersprüchen geht für den Webshop diese Leistungsbeschreibung vor, in Datenschutzfragen der AVV Webshop. + +(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. + +## 2. Leistung + +(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. + +(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. + +(3) Im Kundenportal verwaltet der Händler: +- Artikel mit Beschreibung, Bildern, Preis, Varianten, Bestand und den Pflichtangaben zum Artikel, +- Versand (nur nach Deutschland und Österreich) und Abholung mit Versandkosten und Lieferzeiten, +- Bestellungen mit Status (zum Beispiel bezahlt, versendet, abholbereit, abgeschlossen), die er als CSV-Datei exportieren kann, +- Erstattungen, ganz oder teilweise, über sein Stripe-Konto (Nr. 4), +- die Widerrufe, die Käufer über die Widerrufsfunktion erklärt haben, +- die Verbindung mit seinem Stripe-Konto, seine Einstellungen und seine Rechtstexte, deren bestätigte Fassungen WEBklar archiviert (Nr. 6 Abs. 5). + +(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). + +(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. + +(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. + +(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). + +## 3. Ausgeschlossene Waren und Leistungen + +(1) Über den Webshop dürfen nicht verkauft werden: +- alkoholische Getränke, +- Tabakwaren, E-Zigaretten und Zubehör, +- Arzneimittel und Medizinprodukte, +- Waffen, Munition, Feuerwerk und Sprengstoffe, +- Waren mit erotischem oder pornografischem Inhalt, +- lebende Tiere, +- digitale Inhalte und Downloads, +- Gutscheine, Geschenkkarten und Guthaben, +- Abonnements und andere wiederkehrende Zahlungen, +- Waren und Leistungen, die nach der Liste der verbotenen und eingeschränkten Geschäfte von Stripe nicht erlaubt sind, und +- Waren, deren Verkauf oder Versand nach deutschem, österreichischem oder europäischem Recht verboten ist. + +(2) Der Händler liefert nur nach Deutschland und Österreich. Eine Lieferung in andere Länder bietet der Webshop nicht an. + +(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. + +## 4. Zahlungen über Stripe + +(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. + +(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. + +(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. + +(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). + +(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. + +(6) Die Zahlungsseite selbst (Stripe Checkout) betreibt Stripe. Kartendaten und Kontodaten gibt der Käufer nur dort ein; WEBklar erhält sie nicht. + +## 5. Pflichten des Händlers + +(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. + +(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. + +(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. + +(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. + +(5) Er pflegt Bestand, Bestellstatus und Versandstatus im Kundenportal. Die Folgen falscher Angaben oder Statusmeldungen, die der Händler setzt, trägt er. + +(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. + +(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. + +(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. + +(9) Er hält die Vorgaben nach Nr. 3 ein und verändert den Einbau-Code und die gesetzlich vorgeschriebenen Hinweise des Widgets nicht. + +(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. + +(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. + +## 6. Muster-Rechtstexte, keine Rechtsberatung + +(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. + +(2) Die Mustertexte sind **Muster ohne Gewähr**. 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). + +(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). + +(4) Der Händler bleibt für seine Rechtstexte verantwortlich, auch wenn er Mustertexte unverändert übernimmt (Nr. 5 Abs. 2). + +(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). + +## 7. Verfügbarkeit und Störungen + +(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). + +(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. + +(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. + +(4) Alle Zeiten gelten in der Zeitzone Europe/Berlin. + +(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. + +(6) Die gesetzlichen Rechte des Händlers sowie §§ 12 und 13 der Abo-Bedingungen bleiben unberührt. + +## 8. Haftung + +(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. + +(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. + +(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). + +(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). + +(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. + +## 9. Preis, Abrechnung und Widerruf + +(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). + +(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. + +(3) Der Händler bezahlt bei der Bestellung im Kundenportal über Stripe und erhält eine Rechnung. + +(4) Der Preis steht neben den Leistungen eines Abos: Die Einrichtung wird weder auf ein Stundenkontingent angerechnet noch damit bezahlt. + +(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. + +(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. + +## 10. Laufzeit und Ende + +(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. + +(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. + +(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). + +(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. + +(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). + +(6) Endet der Vertrag über den Betrieb der Website, bevor WEBklar den Webshop freigeschaltet hat, erstattet WEBklar den Einrichtungspreis vollständig. + +(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. + +## 11. Änderungen und Einstellung + +(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. + +(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 +- 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), +- Stripe die Anbindung nach Nr. 4 beendet oder wesentlich ändert, so dass der Webshop in der bisherigen Form nicht weiterbetrieben werden kann, oder +- eine Sicherheitslücke sich nicht anders beheben lässt. + +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. + +(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. + +(4) Eine Einstellung ohne einen Grund nach Absatz 2 ist ausgeschlossen. Gesetzliche Rechte von Verbrauchern bei Änderungen digitaler Produkte (§ 327r BGB) bleiben unberührt. + +## 12. Hinweise + +(1) **Keine Rechtsberatung.** 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. + +(2) **Barrierefreiheit.** 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. diff --git a/docs/rechtliches/termine-datenschutz-6c.md b/docs/rechtliches/termine-datenschutz-6c.md index cb2bedc..59ca842 100644 --- a/docs/rechtliches/termine-datenschutz-6c.md +++ b/docs/rechtliches/termine-datenschutz-6c.md @@ -11,5 +11,5 @@ Fassung 2026-10-05 (Stand der Datenschutzerklärung 05.10.2026). Markdown-Quelle

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.

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.

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.

-

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).

+

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).

``` diff --git a/docs/rechtliches/termine-hinweise.md b/docs/rechtliches/termine-hinweise.md index e99c7b8..4b10f13 100644 --- a/docs/rechtliches/termine-hinweise.md +++ b/docs/rechtliches/termine-hinweise.md @@ -29,7 +29,7 @@ Wortlaut immer in der Sie-Form, auch bei Betrieben, die ihre Gäste duzen: Es si | Haken | Wann | Pflicht | Wortlaut | |---|---|---|---| | Vorzeitiger Beginn | Verbraucher, mit Preis: bei sofort buchbaren Leistungen, wenn der Terminbeginn nicht nach dem Ende der Widerrufsfrist liegt (14 Tage ab dem Tag nach dem Vertragsschluss, Berliner Kalender, §§ 187, 188, 193 BGB; gerechnet mit dem Zeitpunkt der Buchung plus Haltezeit; R29); bei bepreisten Anfragen immer, weil der Tag der Annahme nicht feststeht (R31) | ja | Ich verlange ausdrücklich, dass {{Firma}} mit der Leistung vor Ablauf der Widerrufsfrist beginnt. Mir ist bekannt, dass mein Widerrufsrecht mit der vollständigen Erbringung der Leistung erlischt (§ 356 Abs. 5 Nr. 2 BGB). Widerrufe ich vor der vollständigen Erbringung, schulde ich einen angemessenen Betrag für die bis dahin erbrachte Leistung (§ 357a Abs. 2 BGB). | -| Stornobedingungen | der Betrieb hat Stornobedingungen hinterlegt | ja | Ich habe die Stornobedingungen gelesen. | +| Stornobedingungen | der Betrieb hat Stornobedingungen hinterlegt und die gebuchte Leistung hat einen Preis (bei kostenlosen Leistungen fragt der Terminplaner nicht danach) | ja | Ich habe die Stornobedingungen gelesen. | | Erinnerung | der Betrieb bietet Erinnerungen an | nein | Ich möchte {{n}} Stunden vor dem Termin eine Erinnerung per E-Mail erhalten. Diese Einwilligung kann ich jederzeit widerrufen. | ## 3. Hinweis über dem Nachrichtenfeld diff --git a/docs/rechtliches/termine-kanzlei-paket.md b/docs/rechtliches/termine-kanzlei-paket.md new file mode 100644 index 0000000..b48f1ce --- /dev/null +++ b/docs/rechtliches/termine-kanzlei-paket.md @@ -0,0 +1,966 @@ +# Kanzlei-Paket Terminplaner (WEBklar) + +Dieses Paket gehört zum Verkaufsweg des Terminplaners der WEBklar GbR (Am Schwimmbad 10, 67722 Winnweiler, vertreten durch die Gesellschafter Kenso Grimm und Justin Klein). Es zeigt einer Kanzlei, was verkauft wird, wie der Ablauf ist, welche Texte Kunden und Gäste in Endfassung sehen, wo diese Texte im Programm stehen und was wir wissen möchten. + +Fassungen: Leistungsbeschreibung, Auftragsverarbeitungsvertrag (AVV), Widerrufsbelehrung und Bestelltexte tragen die Fassung **2026-10-05** (Konstante `config.termine.avvVersion`). Die Gästetexte (Hinweise, Haken, Belehrung und Datenschutz-Absatz für die Gäste unserer Kunden) tragen die Fassung **2026-10-05** (Konstante `RECHTSTEXTE_VERSION`). Beide Konstanten haben heute dasselbe Datum, sind aber getrennt und können sich später unterscheiden. + +## 1. Zweck und Umfang + +**Zweck.** Der Inhaber lässt die Texte des Terminplaners durch eine Kanzlei prüfen, bevor der erste fremde Betrieb den Terminplaner nutzt. Bis dahin ist der Terminplaner nicht für fremde Betriebe freigeschaltet. Das Paket soll ohne Rückfragen zeigen, was die Kanzlei prüfen soll und von welchen Annahmen wir ausgehen. + +**Kernpaket.** Der Inhaber hat festgelegt, dass die Kanzlei dieses Kernpaket prüft und alles Übrige WEBklar selbst prüft (Hausrunde). Die Kanzlei prüft: + +- die Widerrufsbelehrung und das Muster-Widerrufsformular der Bestellung der Einrichtung für 149 € (Abschnitt 3.2), +- den Zustimmungssatz zum vorzeitigen Beginn mit Erlöschen des Widerrufsrechts und Wertersatz (Abschnitt 3.1, Haken 3), +- den Auftragsverarbeitungsvertrag Terminplaner (Anlage A, Auszüge in Abschnitt 3.5), +- die Haftungs- und Ende-mit-dem-Abo-Klauseln der Leistungsbeschreibung (Anlage B, Auszüge in Abschnitt 3.4), +- die Gästetexte mit Gesetzlichkeitsfiktion: Widerrufsbelehrung für Gäste und die Hinweise und Haken im Widget (Abschnitte 3.6 bis 3.10). + +**Hausrunde.** Die Datenschutzerklärung von WEBklar (Abschnitt 6c und die Ergänzungen in den Abschnitten 6 und 7), die Hinweise im Kundenportal und die Mailtexte prüft WEBklar selbst. Zu einzelnen Punkten daraus stellen wir trotzdem Fragen (Q15 bis Q19 und Q24); was wir nicht fragen, steht in Abschnitt 6. + +**Begriffe.** *Betriebsart* (im Programm „Modus“) des Terminplaners: `off` (abgeschaltet), `pilot` (nur Kunden auf einer Liste von Pilotadressen) oder `on` (alle Kunden mit Wartungs-Abo). *Pilot* ist ein Kunde auf dieser Liste; derzeit sind das nur Konten des Inhabers. `on` setzt der Inhaber erst nach Ihrem Befund und seiner Freigabe. *Kulanz* heißt, dass das Team einem Kunden den Terminplaner ohne Bestellung und ohne Zahlung freischaltet (Abschnitt 2.1). *Fassung* ist ein Stand der Texte mit Datum. + +**Keine Rechtsberatung durch WEBklar.** Die Texte sind Mustertexte für die Standardkonfiguration und keine Rechtsberatung (Leistungsbeschreibung Nr. 11 Abs. 1). Die rechtlichen Aussagen in diesem Paket (vor allem „unsere Annahme“ bei den Fragen) sind ein Arbeitsstand von WEBklar und kein Rechtsurteil. + +**Fassungen und Änderungen.** Eine ausgelieferte Fassung wird nie überschrieben. Ändern wir nach dem Befund einen Text, bekommt er ein neues Fassungsdatum, neue Dateien (HTML und PDF) und ein neues Archiv der Gästetexte; die Zitate in diesem Paket werden dann angepasst. Ein Test prüft jedes Zitat wörtlich gegen den Quelltext, damit Paket und Programm nicht auseinanderlaufen. + +**Aufbau.** Abschnitt 2 beschreibt den Produktablauf, Abschnitt 3 enthält die Texte, Abschnitt 4 unsere Fragen Q1 bis Q26, Abschnitt 5 die Fundstellen im Programm, Abschnitt 6 was nicht Teil der Prüfung ist, Abschnitt 7 die Checkliste für den Inhaber. Hinter jedem Zitat in Abschnitt 3 steht „⚖ Q…“ mit den Fragen, die den Text betreffen. Anlagen: **Anlage A** ist der AVV Terminplaner, **Anlage B** die Leistungsbeschreibung Terminplaner, beide in voller Länge als PDF (Dateien in Abschnitt 3.11). **Anlage C** sind die gerenderten Mails K15 (Bestellbestätigung mit den Zustimmungen) und K16 (Terminplaner bereit); sie folgt nach der Mail-Überarbeitung des Inhabers, weil ihr Wortlaut bis dahin noch nicht endgültig ist. + +## 2. Produktablauf in Kurzform + +Der Terminplaner ist ein Buchungs-Widget für die Website eines Betriebs (zum Beispiel Friseur, Werkstatt oder Studio) mit einer Verwaltung im Kundenportal von WEBklar. Der Betrieb ist Kunde von WEBklar („Kunde“); seine Besucher, die einen Termin buchen, sind „Gäste“. Verkauft wird die Einrichtung für 149 € einmalig je Website-Projekt, ohne Umsatzsteuer gemäß § 19 UStG. Für den Betrieb berechnet WEBklar neben dem Wartungs-Abo nichts; der Terminplaner läuft, solange das Wartungs-Abo läuft. Bezahlt wird beim Terminplaner nur vor Ort, es gibt keine Online-Zahlung der Gäste. + +### 2.1 Bestellung und Einrichtung + +| Schritt | Was geschieht | Frage | +|---|---|---| +| 1. Abo-Prüfung | Nur Kunden mit laufendem Wartungs-Abo können bestellen; ein Hosting-Abo genügt nicht. Bestellbar ist der Terminplaner nur, solange das Wartungs-Abo aktiv ist und keine Kündigung vorgemerkt ist. | ⚖ Q25 | +| 2. Fragebogen-Entwurf | Der Kunde beantwortet im Kundenportal einen Fragebogen in acht Schritten. Jeder Schritt wird sofort als Entwurf der späteren Einstellungen gespeichert; ein Entwurf ohne Bestellung wird 90 Tage nach der letzten Änderung gelöscht. Die Branchenfrage kennt den Gesundheitsbereich; wählt der Kunde ihn, ist die Bestellung gesperrt. Zusätzlich warnt eine Stichwortprüfung über Betriebsname, Leistungen und Felder das Team, das die Einrichtung ablehnen kann. | ⚖ Q11, Q24 | +| 3. Bestellung mit vier Haken | „Prüfen und bestellen“: Zusammenfassung, offene Punkte (der Server prüft, ob alles Nötige vorliegt, darunter die Adressen von Impressum und Datenschutzerklärung der Website), volle Widerrufsbelehrung mit Muster-Widerrufsformular, vier Haken und der Knopf „Zahlungspflichtig bestellen · 149 €“ (Abschnitte 3.1 und 3.2). Alle Besteller werden wie Verbraucher behandelt. | ⚖ Q1, Q3, Q19, Q20 | +| 4. Nachweis | Der Server legt zuerst die Zahlungssitzung bei Stripe an und speichert dann den Nachweis: die gezeigten Texte, ihre Fassungen, einen Hash, den Zeitpunkt des Klicks, IP-Adresse und Browser-Kennzeichen, Kundenkonto und angemeldete Person. Erst danach erhält der Browser die Adresse der Zahlungsseite. Der Zeitpunkt des Klicks ist der Anker der Widerrufsfrist. | ⚖ Q2, Q23 | +| 5. Zahlung | Stripe Checkout (Karte oder SEPA-Lastschrift); Stripe erstellt die Rechnung. | ⚖ Q19 | +| 6. Ticket und Bestätigung | Nach dem Zahlungseingang legt das Portal ein Einrichtungs-Ticket mit der Einbauanleitung an. Der Kunde erhält die Bestellbestätigung K15 mit den gezeigten Zustimmungen und, als PDF-Anhang auf einem dauerhaften Datenträger, Leistungsbeschreibung, AVV und Widerrufsbelehrung mit Muster-Widerrufsformular in der Fassung, der er zugestimmt hat. Das Team erhält die Meldung T16. | ⚖ Q3, Q20 | +| 7. Einbau und Freischaltung | WEBklar baut das Widget ein, der Kunde nimmt es ab, WEBklar schaltet es frei; der Kunde erhält die Mail K16 („bereit“). | | +| 8. Betrieb | Gäste buchen im Widget (Abschnitt 2.2), der Kunde verwaltet seine Termine im Kundenportal. | | + +**Freischaltung ohne Bestellung (Pilot, Kulanz).** Das Team kann einen Terminplaner ohne Zahlung aktivieren. Sobald die Betriebsart `on` gilt (alle Kunden mit Wartungs-Abo), verlangt das Portal dafür einen Nachweis der Zustimmung des Kunden in Textform (Datum, Weg Chat oder E-Mail, Name des Mitarbeiters), außer für Pilotadressen; danach erhält der Kunde die Mail K16 mit Leistungsbeschreibung und AVV als PDF. Solche Dokumente gelten nicht als durch Kauf freigeschaltet; der tägliche Abo-Abgleich nimmt sie aus (⚖ Q7, Q21). + +### 2.2 Gast: Buchung, Widerruf, Daten + +1. Der Gast wählt Leistung, Termin und gegebenenfalls Person oder Platz und gibt Name und E-Mail-Adresse an (je nach Einstellung des Kunden auch Telefon, Nachricht, Zusatzfelder). Er sieht den Datenschutz-Hinweis (Abschnitt 3.8), die Haken (Abschnitt 3.7) und, bei Verbrauchern, die Widerrufsbelehrung zum Aufklappen (Abschnitt 3.6). ⚖ Q12, Q13, Q17 +2. Die Buchung wird 30 Minuten gehalten. In dieser Zeit bestätigt der Gast per Link in einer E-Mail; erst die Bestätigung macht die Buchung verbindlich, bei einer Terminanfrage nimmt der Kunde später an. Nicht bestätigte Buchungen löscht WEBklar 48 Stunden nach Ablauf der Bestätigungsfrist. ⚖ Q14 +3. Die Bestätigungs-E-Mail (G02) enthält bei Verbrauchern Widerrufshinweis, Widerrufsbelehrung und Muster-Widerrufsformular; bei einer Freizeit-Dienstleistung mit festem Termin, die der Kunde so einstuft, steht stattdessen der Hinweis, dass kein Widerrufsrecht besteht (Abschnitt 3.9). ⚖ Q15, Q16 +4. Widerrufen kann der Gast über den Knopf „Vertrag widerrufen“ im Terminbereich der Website oder über den Link „Termin verwalten“ in der Bestätigungs-E-Mail; er erhält eine Eingangsbestätigung mit Datum und Uhrzeit (G08). Die Widerrufsfrist beträgt 14 Tage. +5. Der Terminplaner setzt keine Cookies und speichert nichts im Browser des Gastes; die IP-Adresse dient dem Missbrauchsschutz nur im Arbeitsspeicher des Servers. Die Buchung speichert als Nachweis die Fassung der Gästetexte, den Zeitpunkt, den Text des Buchungsknopfs, die Anrede und Hashes der gezeigten Texte; WEBklar archiviert jede Fassung der Gästetexte. ⚖ Q26 +6. Löschfristen: bestätigte und erledigte Termine 90 Tage nach dem Terminende (einstellbar 14 bis 365 Tage), abgesagte oder abgelehnte Termine 30 Tage nach der Absage, nicht bestätigte Buchungen 48 Stunden nach Ablauf der Bestätigungsfrist; in der Versandwarteschlange für E-Mails werden Empfängeradresse und Inhalt nach dem Versand geleert und der Eintrag nach 7 Tagen gelöscht; Sicherungen halten gelöschte Daten bis zu sieben Tage. ⚖ Q9 + +### 2.3 Ende des Abos + +1. Ein täglicher Abgleich prüft das Wartungs-Abo. Endet es (auch durch Wechsel ins Hosting-Abo oder durch Widerruf des Abschlusses oder eines Wechsels), schaltet WEBklar den Terminplaner spätestens am folgenden Tag ab und informiert den Kunden mit der Mail K22. Ist etwas nicht lesbar, schaltet der Abgleich nichts ab und versucht es am nächsten Tag erneut. ⚖ Q7 +2. Nach dem Abschalten nimmt der Terminplaner keine neuen Buchungen mehr an. Die Termine bleiben im Kundenportal sichtbar und als CSV-Datei exportierbar, Gäste können über ihren Link absagen, bereits angeforderte Erinnerungen laufen weiter, der Kunde informiert seine Gäste. +3. 30 Tage nach dem Abschalten löscht WEBklar alle Termindaten und die Einstellungen; das Einrichtungs-Ticket des Teams bleibt als Arbeitsnachweis mit einer Kurzfassung der Einstellungen bestehen (keine Daten von Gästen). Beginnt innerhalb dieser 30 Tage wieder ein Wartungs-Abo, schaltet WEBklar den Terminplaner mit den vorhandenen Daten und Einstellungen ohne weitere Kosten wieder ein (Rückkehr, Mail K23). Nach der Löschung sind ein neuer Fragebogen und die Einrichtung für 149 € noch einmal nötig. ⚖ Q9 +4. Endet das Wartungs-Abo, bevor der Terminplaner freigeschaltet ist, erstattet WEBklar die 149 € vollständig. Der Abgleich schaltet den Terminplaner mit dem Grund „Abo endete vor Einrichtung“ ab und meldet dem Team die Erstattung (T19); die Erstattung löst der Inhaber in Stripe aus, es gibt keine automatische Rückzahlung. In diesem Fall gibt es keine automatische Rückkehr. ⚖ Q6 + +### 2.4 Widerruf der Bestellung durch den Kunden + +Der Kunde widerruft auf der Seite „Vertrag widerrufen“ mit der Auswahl „Terminplaner (Einrichtung)“. Jede Bestellung hat ihre eigene Frist von 14 Tagen ab dem Zeitpunkt des Klicks auf „Zahlungspflichtig bestellen“ (bei mehreren Website-Projekten also mehrere Fristen). Die Widerrufsseite ordnet den Widerruf keinem Projekt zu: Sie bewertet den Eingang gegen das späteste Fristende aller bezahlten Terminplaner-Bestellungen des Kunden; das Team erhält in der Meldung T09 jede Bestellung mit Projekt, Zustand und eigenem Fristende und entscheidet. Der Widerruf erzeugt die Mail K04 an den Kunden und die Meldung T09 an das Team; die Abwicklung (Erstattung in Stripe abzüglich Wertersatz für einen schon begonnenen Einbau, Abschalten des Terminplaners) erledigt das Team von Hand. ⚖ Q2, Q5, Q7 + +### 2.5 Zahlen + +| Wert | Bedeutung | Quelle im Code | +|---|---|---| +| 149 € | Einrichtung je Website-Projekt, einmalig, ohne Umsatzsteuer gemäß § 19 UStG | `priceLabel` des Terminplaners, `amountCents` 14900 | +| 14 Tage | Widerrufsfrist | `WITHDRAWAL_DAYS` | +| 30 Minuten | Haltezeit einer Buchung, Gültigkeit des Bestätigungslinks | `holdMinutes` | +| 48 Stunden | Löschung nicht bestätigter Buchungen nach Ablauf der Bestätigungsfrist | `VERFALLEN_BEHALTEN_MS` | +| 90 Tage | Aufbewahrung bestätigter Termine nach dem Terminende (Vorgabe) und Aufbewahrung von Entwürfen ohne Bestellung | `AUFBEWAHRUNG_VORGABE`, `ENTWURF_TAGE` | +| 7 Tage | Eintrag in der Versandwarteschlange nach dem Versand; Sicherungen des Servers | `GESENDET_BEHALTEN_MS` | +| 30 Tage | Löschung nach dem Abschalten; Löschung abgesagter oder abgelehnter Termine | `LOESCHFRIST_TAGE`, `ABGESAGT_BEHALTEN_MS` | + +## 3. Texte in Endfassung + +Alle Zitate stehen wörtlich so im Programm oder in den Dokumenten (Abschnitt 5 nennt die Fundstellen). Platzhalter in doppelten geschweiften Klammern stehen für Angaben des jeweiligen Betriebs. In den Zitaten steht vor dem Sternchen in Klammern (\*) und vor spitzen Klammern ein Rückstrich, damit die Darstellung sie nicht als Hervorhebung oder Markup deutet; gemeint sind die Zeichen selbst. + +### 3.1 Bestellung des Terminplaners: vier Haken und Knopf + +Auf der Bestellseite stehen vor dem Knopf die volle Widerrufsbelehrung mit Muster-Widerrufsformular (Abschnitt 3.2) und diese vier Haken, keiner ist vorangekreuzt. Die Links auf Leistungsbeschreibung, AVV und Widerrufsbelehrung in der Fassung stehen daneben, nicht im Text der Haken. Gespeichert wird genau der gezeigte Text mit Fassung und Zeitpunkt. + +**Haken 1 (`unterlagen`): Zustimmung zu Leistungsbeschreibung und AVV** + +> Ich stimme der Leistungsbeschreibung Terminplaner und dem Auftragsverarbeitungsvertrag Terminplaner in der Fassung 2026-10-05 zu. + +⚖ Q1, Q10 + +**Haken 2 (`gesundheit`): kein Gesundheitsbereich** + +> Mein Angebot liegt nicht im Gesundheitsbereich (zum Beispiel keine Arzt-, Zahnarzt-, Psychotherapie-, Heilpraktiker- oder Physiotherapiepraxis). + +⚖ Q11 + +**Haken 3 (`beginn`): vorzeitiger Beginn, Erlöschen, Wertersatz** + +> Ich verlange ausdrücklich, dass WEBklar vor Ablauf der Widerrufsfrist mit der Einrichtung des Terminplaners beginnt. Mir ist bekannt, dass mein Widerrufsrecht mit der vollständigen Erbringung der Einrichtung erlischt (§ 356 Abs. 5 Nr. 2 BGB). Widerrufe ich vor der vollständigen Erbringung, schulde ich einen angemessenen Betrag für die bis dahin erbrachte Leistung (§ 357a Abs. 2 BGB). + +⚖ Q3, Q5 + +**Haken 4 (`information`): Pflichtinformationen** + +> Ich habe die Pflichtinformationen zu dieser Bestellung gelesen: Leistung (Einrichtung des Terminplaners für diese Website: ein Widget an einer Stelle, gestaltet nach dem Fragebogen, mit Einbau und Datenschutz-Absatz), Gesamtpreis 149 € einmalig (ohne Umsatzsteuer gemäß § 19 UStG), voraussichtliche Dauer meist innerhalb von 1–2 Wochen, der Terminplaner endet mit dem Wartungs-Abo. Die Widerrufsbelehrung mit Muster-Widerrufsformular habe ich gelesen. + +⚖ Q7, Q19, Q20 + +**Knopf** + +> Zahlungspflichtig bestellen · 149 € + +⚖ Q2, Q19 + +### 3.2 Widerrufsbelehrung und Muster-Widerrufsformular der Funktionen + +WEBklar ist hier der Unternehmer. Die Belehrung steht auf der Bestellseite, im PDF-Anhang der Bestellbestätigung K15 und in der Datei `docs/rechtliches/widerrufsbelehrung-funktionen.md` (Fassung 2026-10-05). + +**Widerrufsbelehrung und Muster-Widerrufsformular (Bestellung der Einrichtung)** + +> Widerrufsbelehrung +> +> Widerrufsrecht +> +> Sie haben das Recht, binnen vierzehn Tagen ohne Angabe von Gründen diesen Vertrag zu widerrufen. +> +> Die Widerrufsfrist beträgt vierzehn Tage ab dem Tag des Vertragsabschlusses. +> +> Um Ihr Widerrufsrecht auszuüben, müssen Sie uns (WEBklar GbR, Am Schwimmbad 10, 67722 Winnweiler, Telefon: 0176 23726355, E-Mail: support@webklar.com) mittels einer eindeutigen Erklärung (z. B. ein mit der Post versandter Brief oder eine E-Mail) über Ihren Entschluss, diesen Vertrag zu widerrufen, informieren. Sie können dafür das beigefügte Muster-Widerrufsformular verwenden, das jedoch nicht vorgeschrieben ist. +> +> Sie können Ihr Widerrufsrecht auch online unter https://project.webklar.com/widerrufen.html ausüben. Wenn Sie diese Online-Funktion nutzen, übermitteln wir Ihnen auf einem dauerhaften Datenträger (z. B. durch eine E-Mail) unverzüglich eine Eingangsbestätigung mit Informationen zum Inhalt der Widerrufserklärung sowie dem Datum und der Uhrzeit ihres Eingangs. +> +> Zur Wahrung der Widerrufsfrist reicht es aus, dass Sie die Mitteilung über die Ausübung des Widerrufsrechts vor Ablauf der Widerrufsfrist absenden. +> +> Folgen des Widerrufs +> +> Wenn Sie diesen Vertrag widerrufen, haben wir Ihnen alle Zahlungen, die wir von Ihnen erhalten haben, einschließlich der Lieferkosten (mit Ausnahme der zusätzlichen Kosten, die sich daraus ergeben, dass Sie eine andere Art der Lieferung als die von uns angebotene, günstigste Standardlieferung gewählt haben), unverzüglich und spätestens binnen vierzehn Tagen ab dem Tag zurückzuzahlen, an dem die Mitteilung über Ihren Widerruf dieses Vertrags bei uns eingegangen ist. Für diese Rückzahlung verwenden wir dasselbe Zahlungsmittel, das Sie bei der ursprünglichen Transaktion eingesetzt haben, es sei denn, mit Ihnen wurde ausdrücklich etwas anderes vereinbart; in keinem Fall werden Ihnen wegen dieser Rückzahlung Entgelte berechnet. +> +> Haben Sie verlangt, dass die Dienstleistungen während der Widerrufsfrist beginnen soll, so haben Sie uns einen angemessenen Betrag zu zahlen, der dem Anteil der bis zu dem Zeitpunkt, zu dem Sie uns von der Ausübung des Widerrufsrechts hinsichtlich dieses Vertrags unterrichten, bereits erbrachten Dienstleistungen im Vergleich zum Gesamtumfang der im Vertrag vorgesehenen Dienstleistungen entspricht. +> +> Muster-Widerrufsformular +> +> (Wenn Sie den Vertrag widerrufen wollen, dann füllen Sie bitte dieses Formular aus und senden Sie es zurück.) +> +> – An WEBklar GbR, Am Schwimmbad 10, 67722 Winnweiler, E-Mail: support@webklar.com: +> – Hiermit widerrufe(n) ich/wir (\*) den von mir/uns (\*) abgeschlossenen Vertrag über den Kauf der folgenden Waren (\*)/die Erbringung der folgenden Dienstleistung (\*) +> – Bestellt am (\*)/erhalten am (\*) +> – Name des/der Verbraucher(s) +> – Anschrift des/der Verbraucher(s) +> – Unterschrift des/der Verbraucher(s) (nur bei Mitteilung auf Papier) +> – Datum +> +> (\*) Unzutreffendes streichen. + +⚖ Q1, Q2, Q12 + +Das Dokument enthält vor der Belehrung einen Einleitungssatz und nach dem Muster-Widerrufsformular „Hinweise zum Widerruf“, die nicht zur gesetzlichen Belehrung gehören: + +**Einleitung des Dokuments** + +> Das Widerrufsrecht gilt nur für Verbraucher. Wir zeigen die Belehrung und das Muster-Widerrufsformular trotzdem allen, die eine Funktion bestellen. Die Belehrung gilt für die Bestellung der Einrichtung einer Funktion im Kundenportal, zum Beispiel des Terminplaners. Hinweise dazu stehen nach dem Muster-Widerrufsformular. + +⚖ Q1 + +**Hinweise zum Widerruf** + +> Diese Hinweise gehören nicht zur gesetzlichen Widerrufsbelehrung. Sie erklären, wie sich das Widerrufsrecht auf die Bestellung einer Funktion auswirkt. +> +> - Das Widerrufsrecht gilt für jede Bestellung einer Funktion gesondert, also für jedes Website-Projekt, für das der Kunde eine Funktion bestellt. Die Frist von vierzehn Tagen beginnt jeweils mit dem Vertragsschluss. +> - Widerruft der Kunde die Einrichtung einer Funktion, endet diese Funktion. Das Wartungs-Abo bleibt davon unberührt. +> - Hat WEBklar auf ausdrücklichen Wunsch des Kunden vor Ablauf der Widerrufsfrist mit der Einrichtung begonnen, schuldet der Kunde bei einem Widerruf einen angemessenen Betrag für die bis dahin erbrachte Leistung. Der Betrag bemisst sich als Anteil am Gesamtpreis; Maß ist der im Arbeitsblatt erfasste Aufwand für die Einrichtung. +> - Die Rückzahlung erfolgt über das ursprüngliche Zahlungsmittel; die Zahlung lief über Stripe. + +⚖ Q5, Q7 + +### 3.3 Zustimmung bei der Bestellung der News-Pinnwand (Altbestand) + +Die News-Pinnwand (30 € einmalig) wird seit 2026-09-30 über denselben Bestellweg verkauft, bisher mit einem Haken und ohne Widerrufsbelehrung. Zum Vergleich der Text, den neue Bestellungen jetzt sehen, und der Haken, der bis zur Korrektur galt (Programmstand 3a0b008 vor der Korrektur). Die Leistungsbeschreibung der News-Pinnwand aus dieser Zeit steht in Frage Q4. + +**Neuer Haken der News-Pinnwand** + +> Ich verlange ausdrücklich, dass WEBklar vor Ablauf der Widerrufsfrist mit dem Einbau beginnt. Mir ist bekannt, dass mein Widerrufsrecht mit der vollständigen Erbringung des Einbaus erlischt (§ 356 Abs. 5 Nr. 2 BGB). Widerrufe ich vor der vollständigen Erbringung, schulde ich einen angemessenen Betrag für die bis dahin erbrachte Leistung (§ 357a Abs. 2 BGB). + +⚖ Q4, Q3 + +**Alte Fassung (Stand 3a0b008): früherer Haken der News-Bestellung** + +> Ich verlange ausdrücklich, dass WEBklar vor Ablauf der Widerrufsfrist mit dem Einbau beginnt. Mir ist bekannt, dass mein Widerrufsrecht erlischt, sobald der Einbau vollständig erbracht ist (§ 356 Abs. 4 BGB). + +⚖ Q4 + +### 3.4 Leistungsbeschreibung Terminplaner (Anlage B), Auszüge + +Die Datei ist `docs/rechtliches/leistungsbeschreibung-terminplaner.md`; sie ist als Anlage B vollständig beigefügt. Zitiert sind die Nummern, auf die sich unsere Fragen beziehen; weitere Nummern behandeln Geltung, Leistung, Voraussetzungen und Verfügbarkeit. + +**Nr. 4 Abs. 3: Gesundheitsbereich** + +> (3) Der Terminplaner ist nicht für Angebote im Gesundheitsbereich bestimmt, weil dort besondere Kategorien personenbezogener Daten anfallen können (Art. 9 DSGVO). Der Kunde setzt ihn dort nicht ein und fordert von seinen Gästen keine Gesundheitsangaben. WEBklar darf die Einrichtung für eine solche Website ablehnen oder den Terminplaner dort abschalten. + +⚖ Q11 + +**Nr. 4 Abs. 9: Datenschutz-Absatz auf der Website des Kunden** + +> (9) Liegt die Datenschutzerklärung in der von WEBklar betreuten Website, fügt WEBklar beim Einbau dort den Datenschutz-Absatz für die Online-Terminbuchung ein; das gehört zur Einrichtung und wird nicht auf das Stundenkontingent angerechnet. Liegt sie woanders, etwa bei einem externen Generator, erhält der Kunde den Absatz als Textbaustein und fügt ihn selbst ein. Für den übrigen Inhalt seiner Datenschutzerklärung bleibt der Kunde verantwortlich (§ 6 Abs. 3 der Abo-Bedingungen). Fällt WEBklar beim Einfügen ein offensichtlicher Widerspruch auf, weist WEBklar den Kunden darauf hin; eine Pflicht zur Prüfung der Datenschutzerklärung entsteht daraus nicht. + +⚖ Q18 + +**Nr. 5: Einrichtung und Preis** + +> (1) Die Einrichtung umfasst die Einrichtung nach dem Fragebogen, den Einbau des Widgets in die Website, den Datenschutz-Absatz nach Nr. 4 Abs. 9, die Abnahme durch den Kunden und die Freischaltung durch WEBklar. +> +> (2) Die Einrichtung kostet 149 € einmalig je Website-Projekt, ohne Umsatzsteuer gemäß § 19 UStG. +> +> (3) Die voraussichtliche Dauer der Einrichtung nennt die Bestellseite vor der Bestellung. Sie ist eine Einschätzung und keine verbindliche Zusage eines Termins. +> +> (4) Der Kunde bezahlt bei der Bestellung im Kundenportal über Stripe und erhält eine Rechnung. +> +> (5) Der Preis steht neben dem Stundenkontingent des Wartungs-Abos: Die Einrichtung wird weder auf das Kontingent angerechnet noch damit bezahlt. Für den Betrieb des Terminplaners berechnet WEBklar neben dem Wartungs-Abo nichts weiter; er läuft, solange das Wartungs-Abo läuft (Nr. 8). + +⚖ Q19, Q20 + +**Nr. 7: Haftung für verpasste Termine** + +> (1) Der Terminplaner ist ein Hilfsmittel. Die Einhaltung der Termine bleibt Sache des Kunden. +> +> (2) WEBklar übernimmt keine Gewähr für die Zustellung der E-Mails (Nr. 6 Abs. 4). +> +> (3) Für entgangenen Gewinn und Folgeschäden aus verpassten Terminen haftet WEBklar nur im Rahmen von § 13 Abs. 1 und 2 der Abo-Bedingungen. +> +> (4) Der Haftungsrahmen des Kunden gegenüber seinen Gästen bleibt unberührt. + +⚖ Q8 + +**Nr. 8: Ende der Leistung** + +> (1) Mit dem Ende des Wartungs-Abos endet der Terminplaner. WEBklar schaltet ihn spätestens am folgenden Tag ab und informiert den Kunden per E-Mail. Das gilt auch bei einem Wechsel ins Hosting-Abo und beim Widerruf des Abschlusses oder eines Wechsels ins Wartungs-Abo. +> +> (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. +> +> (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). +> +> (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). +> +> (5) Endet das Wartungs-Abo, bevor WEBklar den Terminplaner freigeschaltet hat, erstattet WEBklar den Einrichtungspreis vollständig. +> +> (6) Auf Wunsch des Kunden in Textform schaltet WEBklar den Terminplaner jederzeit ab. Eine Erstattung des Einrichtungspreises folgt daraus nicht; für den Widerruf gilt Nr. 10. + +⚖ Q6, Q7, Q9 + +**Nr. 9 Abs. 4: Einstellung ohne Grund** + +> (4) Eine Einstellung ohne einen Grund nach Absatz 2 ist ausgeschlossen. Gesetzliche Rechte von Verbrauchern bei Änderungen digitaler Produkte (§ 327r BGB) bleiben unberührt. + +⚖ Q8 + +**Nr. 10: Widerruf** + +> (1) 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)“. +> +> (2) Ob das Widerrufsrecht erlischt und ob der Kunde einen angemessenen Betrag für die bis dahin erbrachte Leistung schuldet, richtet sich nach der Zustimmung, die der Kunde bei der Bestellung gibt, und nach den Hinweisen zum Widerruf in der Anlage. +> +> (3) Mit dem Widerruf endet der Terminplaner. + +⚖ Q3, Q5, Q7 + +**Nr. 11: Hinweise** + +> (1) **Keine Rechtsberatung.** Der Hinweis am Buchungsformular, die Texte der Haken, der Datenschutz-Absatz und die Widerrufsbelehrung für Gäste sind Mustertexte für die Standardkonfiguration. WEBklar sorgt dafür, dass sie die Technik richtig beschreiben, und passt sie an, wenn sich die Technik ändert. Die Einstufung seiner Leistungen als Leistung für Verbraucher oder Unternehmer und als Freizeitleistung trägt der Kunde. Eine rechtliche Beratung im Einzelfall schuldet WEBklar nicht. +> +> (2) **Barrierefreiheit.** 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 Kunde als Dienstleistungserbringer unter das BFSG fällt (Ausnahme für Kleinstunternehmen, § 3 Abs. 3 BFSG), entscheidet und prüft der Kunde. + +⚖ Q15, Q18, Q22 + +### 3.5 Auftragsverarbeitungsvertrag Terminplaner (Anlage A), Auszüge + +Die Datei ist `docs/rechtliches/avv-terminplaner.md`; sie ist als Anlage A vollständig beigefügt (mit der Anlage der technischen und organisatorischen Maßnahmen). + +**Vorbemerkung Abs. 1 und 2: Abschluss** + +> (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. +> +> (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. + +⚖ Q10, Q21 + +**Nr. 3 Abs. 1: Daten der Gäste** + +> (1) Daten der Gäste: Name und E-Mail-Adresse; auf Einstellung des Kunden außerdem Telefonnummer, eine Nachricht und Zusatzfelder, die der Kunde im Formular festlegt; die gebuchte Leistung, die Person oder der Platz und der Termin; der Status der Buchung mit den Zeitpunkten (Anlegen, Bestätigung, Annahme, Absage, Widerruf); Absagegrund und Vermerke zu einer Absage oder einem Widerruf (Freitext, auch mit dem Namen aus einer Widerrufserklärung); Preis, Zahlart, Zahlungsstatus und die Angabe, ob als Verbraucher gebucht wurde; der Nachweis der Zustimmungen (Fassung der Gästetexte, Zeitpunkt, Text des Buchungsknopfs, Anrede und Hashes (Prüfsummen) der gezeigten Texte); der Wunsch nach einer Erinnerung; der Zustellstatus der E-Mails an den Gast. + +⚖ Q10 + +**Nr. 4 Abs. 2: Weisungen** + +> (2) Mit der Bestellung weist der Kunde WEBklar an, den Terminplaner nach seinen Einstellungen und der Leistungsbeschreibung zu betreiben. Weitere Weisungen ergeben sich aus den Einstellungen im Kundenportal (Leistungen, Personen oder Plätze, Zeiten, Felder des Formulars, Aufbewahrungsdauer, Erinnerungen, Einschalten und Ausschalten). 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 Terminplaner abschalten lassen (Nr. 8 Abs. 6 der Leistungsbeschreibung). Die Löschung auf Weisung nach Nummer 9 Abs. 6 setzt WEBklar immer um. + +⚖ Q10 + +**Nr. 7: Unterauftragsverarbeiter** + +> (1) Der Kunde genehmigt den Einsatz der Hetzner Online GmbH, Industriestraße 25, 91710 Gunzenhausen, für den Betrieb des Servers, auf dem der Terminplaner läuft, und für dessen Sicherung (Rechenzentrum Falkenstein, Deutschland) sowie für den Versand der Buchungs-E-Mails über den SMTP-Dienst des Webhostings (Rechenzentrum Nürnberg, Deutschland). Eine Übermittlung in Drittländer findet nicht statt. +> +> (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 Terminplaner 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). +> +> (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). +> +> (4) Die Zahlung der Einrichtung über Stripe betrifft nur den Vertrag zwischen dem Kunden und WEBklar. Daten von Gästen oder Beschäftigten des Kunden gehen nicht an Stripe. Stripe ist deshalb kein Unterauftragsverarbeiter dieses Vertrags. + +⚖ Q10 + +**Nr. 8 Abs. 5: Nachweis und Archiv der Gästetexte** + +> (5) Die Buchung speichert als Nachweis der Zustimmungen die Fassung der Gästetexte, den Zeitpunkt, den Text des Buchungsknopfs, die Anrede und Hashes der übrigen Texte, die der Gast gesehen hat, nicht diese Texte selbst. Damit sich der Wortlaut nachweisen lässt, bewahrt WEBklar jede Fassung der Gästetexte (Hinweis am Formular, Texte der Haken, Datenschutz-Absatz und Widerrufsbelehrung für Gäste) mit dem Zeitraum ihrer Verwendung auf, bis drei Jahre nach dem Ende des Terminplaners. WEBklar stellt sie dem Kunden auf Verlangen zur Verfügung. + +⚖ Q26 + +**Nr. 9: Löschung und Rückgabe** + +> (1) Nicht bestätigte Buchungen löscht WEBklar 48 Stunden nach Ablauf der Bestätigungsfrist von 30 Minuten. +> +> (2) Abgesagte und abgelehnte Termine löscht WEBklar 30 Tage nach der Absage oder Ablehnung. Terminanfragen, die der Kunde bis zum angefragten Terminbeginn nicht beantwortet, behandelt der Terminplaner als abgelehnt; die 30 Tage laufen dann ab diesem Zeitpunkt. +> +> (3) Bestätigte und erledigte Termine löscht WEBklar nach der Aufbewahrungsdauer, gerechnet ab dem Ende des Termins. Der Kunde stellt sie im Kundenportal ein; die Vorgabe sind 90 Tage, einstellbar sind 14 bis 365 Tage. +> +> (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. +> +> (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. +> +> (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. +> +> (7) Entwürfe aus dem Fragebogen, aus denen keine Bestellung geworden ist, löscht WEBklar 90 Tage nach der letzten Änderung. +> +> (8) Die Daten sind in der täglichen Vollsicherung des Servers enthalten (§ 3 Abs. 5 der Abo-Bedingungen). Gelöschte 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 nach; Löschungen auf Weisung des Kunden wiederholt WEBklar. +> +> (9) Gesetzliche Aufbewahrungspflichten bleiben unberührt. + +⚖ Q7, Q9, Q10 + +### 3.6 Gäste: Widerrufsbelehrung und Muster-Widerrufsformular + +Der Terminplaner erzeugt diese Belehrung mit den Angaben des Betriebs (alle vier Angaben sind Pflicht) und zeigt sie Verbrauchern beim Buchen und in der Bestätigungs-E-Mail. Grundlage ist das Muster der Anlage 1 und das Muster-Widerrufsformular der Anlage 2 zu Art. 246a § 1 Abs. 2 Satz 2 EGBGB (Dateien `docs/rechtliches/quellen/anlage-1.txt` und `anlage-2.txt`, abgerufen 2026-10-02). Gestaltungshinweise für einen Dienstleistungsvertrag sind angewendet: Fristbeginn „des Vertragsabschlusses“, Wertersatz für Dienstleistungen; die Widerrufsfunktion nennt keine Internetadresse, sondern einen anderen geeigneten Hinweis. Ausgelassen sind die Textbausteine zu Waren und der Absatz zum elektronischen Webformular. Die Anrede ist immer „Sie“. + +**Widerrufsbelehrung und Muster-Widerrufsformular für Gäste (Platzhalter für die Betriebsangaben)** + +> Widerrufsbelehrung +> +> Widerrufsrecht +> +> Sie haben das Recht, binnen vierzehn Tagen ohne Angabe von Gründen diesen Vertrag zu widerrufen. +> +> Die Widerrufsfrist beträgt vierzehn Tage ab dem Tag des Vertragsabschlusses. +> +> Um Ihr Widerrufsrecht auszuüben, müssen Sie uns ({{Name des Betriebs}}, {{Anschrift}}, Telefon: {{Telefon}}, E-Mail: {{E-Mail}}) mittels einer eindeutigen Erklärung (z. B. ein mit der Post versandter Brief oder eine E-Mail) über Ihren Entschluss, diesen Vertrag zu widerrufen, informieren. Sie können dafür das beigefügte Muster-Widerrufsformular verwenden, das jedoch nicht vorgeschrieben ist. +> +> Sie können Ihr Widerrufsrecht auch online unter „Vertrag widerrufen“ (Schaltfläche im Terminbereich auf unserer Website sowie Link „Termin verwalten“ in Ihrer Bestätigungs-E-Mail) ausüben. Wenn Sie diese Online-Funktion nutzen, übermitteln wir Ihnen auf einem dauerhaften Datenträger (z. B. durch eine E-Mail) unverzüglich eine Eingangsbestätigung mit Informationen zum Inhalt der Widerrufserklärung sowie dem Datum und der Uhrzeit ihres Eingangs. +> +> Zur Wahrung der Widerrufsfrist reicht es aus, dass Sie die Mitteilung über die Ausübung des Widerrufsrechts vor Ablauf der Widerrufsfrist absenden. +> +> Folgen des Widerrufs +> +> Wenn Sie diesen Vertrag widerrufen, haben wir Ihnen alle Zahlungen, die wir von Ihnen erhalten haben, einschließlich der Lieferkosten (mit Ausnahme der zusätzlichen Kosten, die sich daraus ergeben, dass Sie eine andere Art der Lieferung als die von uns angebotene, günstigste Standardlieferung gewählt haben), unverzüglich und spätestens binnen vierzehn Tagen ab dem Tag zurückzuzahlen, an dem die Mitteilung über Ihren Widerruf dieses Vertrags bei uns eingegangen ist. Für diese Rückzahlung verwenden wir dasselbe Zahlungsmittel, das Sie bei der ursprünglichen Transaktion eingesetzt haben, es sei denn, mit Ihnen wurde ausdrücklich etwas anderes vereinbart; in keinem Fall werden Ihnen wegen dieser Rückzahlung Entgelte berechnet. +> +> Haben Sie verlangt, dass die Dienstleistungen während der Widerrufsfrist beginnen soll, so haben Sie uns einen angemessenen Betrag zu zahlen, der dem Anteil der bis zu dem Zeitpunkt, zu dem Sie uns von der Ausübung des Widerrufsrechts hinsichtlich dieses Vertrags unterrichten, bereits erbrachten Dienstleistungen im Vergleich zum Gesamtumfang der im Vertrag vorgesehenen Dienstleistungen entspricht. +> +> Muster-Widerrufsformular +> +> (Wenn Sie den Vertrag widerrufen wollen, dann füllen Sie bitte dieses Formular aus und senden Sie es zurück.) +> +> – An {{Name des Betriebs}}, {{Anschrift}}, {{E-Mail}}: +> – Hiermit widerrufe(n) ich/wir (\*) den von mir/uns (\*) abgeschlossenen Vertrag über den Kauf der folgenden Waren (\*)/die Erbringung der folgenden Dienstleistung (\*) +> – Bestellt am (\*)/erhalten am (\*) +> – Name des/der Verbraucher(s) +> – Anschrift des/der Verbraucher(s) +> – Unterschrift des/der Verbraucher(s) (nur bei Mitteilung auf Papier) +> – Datum +> +> (\*) Unzutreffendes streichen. + +⚖ Q12, Q13 + +### 3.7 Gäste: Haken und Hinweis zu Gesundheitsangaben + +Die Haken stehen immer in der Sie-Form. Keiner ist vorangekreuzt. + +**Haken „Vorzeitiger Beginn“** (Pflicht, nur für Verbraucher bei Leistungen mit Preis, wenn der Termin vor Ablauf der Widerrufsfrist liegt; bei Anfragen mit Preis immer) + +> Ich verlange ausdrücklich, dass {{Firma}} mit der Leistung vor Ablauf der Widerrufsfrist beginnt. Mir ist bekannt, dass mein Widerrufsrecht mit der vollständigen Erbringung der Leistung erlischt (§ 356 Abs. 5 Nr. 2 BGB). Widerrufe ich vor der vollständigen Erbringung, schulde ich einen angemessenen Betrag für die bis dahin erbrachte Leistung (§ 357a Abs. 2 BGB). + +⚖ Q13, Q3 + +**Haken „Stornobedingungen“** (Pflicht, wenn der Betrieb Stornobedingungen hinterlegt hat und die Leistung einen Preis hat) + +> Ich habe die Stornobedingungen gelesen. + +⚖ Q16 + +**Haken „Erinnerung“** (freiwillig; nur wenn der Betrieb Erinnerungen anbietet; `{{n}}` ist 2, 12, 24 oder 48) + +> Ich möchte {{n}} Stunden vor dem Termin eine Erinnerung per E-Mail erhalten. Diese Einwilligung kann ich jederzeit widerrufen. + +⚖ Q17 + +**Hinweis über jedem Freitextfeld** + +> Bitte keine Gesundheitsangaben. + +⚖ Q11 + +### 3.8 Gäste: Datenschutz-Hinweis am Buchungsformular + +Der Hinweis steht in der Zusammenfassung vor dem Absenden, zusammen mit einem Link auf die Datenschutzerklärung des Betriebs; es gibt keinen Haken dafür. Sie- oder Du-Fassung nach der Anrede des Betriebs; die Löschfristen sind die des Löschlaufs. + +**Fassung Sie** + +> Datenschutz. Wir verwenden Ihre Angaben, um Ihren Termin zu verwalten und zu bestätigen (Art. 6 Abs. 1 lit. b DSGVO). Betrieben wird der Terminkalender in unserem Auftrag von der WEBklar GbR (Server: Hetzner, Deutschland). Wir speichern Ihre Daten bis 90 Tage nach dem Termin, abgesagte Termine 30 Tage, nicht bestätigte Buchungen 48 Stunden nach Ablauf der Bestätigungsfrist. Es werden keine Cookies gesetzt. Bitte machen Sie keine Angaben zu Ihrer Gesundheit. Mehr erfahren Sie in unserer Datenschutzerklärung. Verantwortlich: {{Firma}}, {{Anschrift}}. + +⚖ Q17, Q9 + +**Fassung Du** + +> Datenschutz. Wir verwenden deine Angaben, um deinen Termin zu verwalten und zu bestätigen (Art. 6 Abs. 1 lit. b DSGVO). Betrieben wird der Terminkalender in unserem Auftrag von der WEBklar GbR (Server: Hetzner, Deutschland). Wir speichern deine Daten bis 90 Tage nach dem Termin, abgesagte Termine 30 Tage, nicht bestätigte Buchungen 48 Stunden nach Ablauf der Bestätigungsfrist. Es werden keine Cookies gesetzt. Bitte mach keine Angaben zu deiner Gesundheit. Mehr erfährst du in unserer Datenschutzerklärung. Verantwortlich: {{Firma}}, {{Anschrift}}. + +⚖ Q17, Q9 + +### 3.9 Gäste: Hinweise in der Bestätigungs-E-Mail (G02) + +Die Bestätigungs-E-Mail (Vorlage `server/mail-templates/G02 Termin bestaetigt.html`) enthält je nach Buchung einen der beiden folgenden Hinweise (hier die Sie-Fassung); danach folgt bei Widerrufsrecht die Belehrung aus Abschnitt 3.6. Die Du-Fassung ist inhaltlich gleich. + +**Widerrufsrecht besteht (Verbraucher)** + +> Widerrufsrecht. Wenn Sie Verbraucher sind, können Sie diesen Vertrag innerhalb von 14 Tagen ab Vertragsschluss ohne Angabe von Gründen widerrufen. Bis zum Beginn des Termins geht das über den Knopf „Vertrag widerrufen“; danach über das Formular „Vertrag widerrufen“ auf der Website von {{betrieb_name}} oder per E-Mail an {{betrieb_email}}. Die ausführliche Widerrufsbelehrung und das Muster-Widerrufsformular finden Sie weiter unten in dieser E-Mail. + +⚖ Q2, Q12 + +**Kein Widerrufsrecht (Freizeit-Dienstleistung mit festem Termin)** + +> Für diese Buchung besteht kein Widerrufsrecht (§ 312g Abs. 2 Nr. 9 BGB). Absagen richten sich nach den Stornobedingungen. + +⚖ Q15, Q16 + +### 3.10 Gäste: Knopftexte + +Die Knopftexte hängen davon ab, ob die Buchung verbindlich oder eine Anfrage ist und ob die Leistung einen Preis hat. Im Widget und auf der Bestätigungsseite heißen die Knöpfe: + +**Verbindliche Buchung mit Preis: Widget, dann Bestätigungsseite** + +> Zahlungspflichtig buchen +> +> Zahlungspflichtig bestätigen + +⚖ Q14 + +**Verbindliche Buchung ohne Preis: Widget, dann Bestätigungsseite** + +> Termin buchen +> +> Termin bestätigen + +⚖ Q14 + +**Anfrage: Widget, dann Bestätigungsseite** + +> Termin anfragen +> +> Anfrage absenden + +⚖ Q14 + +**Widerrufsfunktion: Knopf im Terminbereich, dann Bestätigung des Formulars** + +> Vertrag widerrufen +> +> Widerruf bestätigen + +⚖ Q12, Q14 + +### 3.11 Anlagen A und B + +- **Anlage A:** Auftragsverarbeitungsvertrag Terminplaner, Fassung 2026-10-05, als PDF `public/rechtliches/avv-terminplaner-2026-10-05.pdf` (Quelle `docs/rechtliches/avv-terminplaner.md`). +- **Anlage B:** Leistungsbeschreibung Terminplaner, Fassung 2026-10-05, als PDF `public/rechtliches/leistungsbeschreibung-terminplaner-2026-10-05.pdf` (Quelle `docs/rechtliches/leistungsbeschreibung-terminplaner.md`). +- Die Widerrufsbelehrung mit Muster-Widerrufsformular der Funktionen steht in Abschnitt 3.2 vollständig; ihr PDF ist `public/rechtliches/widerrufsbelehrung-funktionen-2026-10-05.pdf`. +- **Anlage C** (gerenderte Mails K15 und K16) wird nach der Mail-Überarbeitung des Inhabers nachgereicht (Abschnitt 7.4). + +## 4. Fragen an die Kanzlei + +Jede Frage hat vier Teile. **Frage:** was wir wissen möchten. **Fundstelle im Text:** wo der Text steht (Abschnitt 3, Anlage A oder B, Programm) und, als Kennung H1 bis C8, welche Punkte der früheren Listen offener Rechtsfragen die Frage mit abdeckt (Abschnitt 6.3 ordnet alle Kennungen zu). **Unsere Annahme:** der Stand, mit dem wir arbeiten; bitte bestätigen oder korrigieren. **Alternative:** was wir stattdessen täten. Die Zählung von § 356 BGB haben wir am 2026-10-05 auf gesetze-im-internet.de abgeglichen; für die übrigen Normen bitten wir um Gegenprüfung (Q3). Einordnungen wie „Annahme“ sind keine Rechtsurteile. + +### Q1: Alle Besteller wie Verbraucher + +**Frage.** Der Inhaber hat entschieden, alle Besteller der Einrichtung (149 €) wie Verbraucher zu behandeln: Alle sehen die volle Widerrufsbelehrung mit Muster-Widerrufsformular und alle setzen die Haken zum vorzeitigen Beginn. Die Besteller sind meist Unternehmer (Betriebe mit eigener Website); ob jemand als Verbraucher (§ 13 BGB) oder Unternehmer (§ 14 BGB) bestellt, kann WEBklar nicht verlässlich prüfen (Existenzgründer, Nebenerwerb). Genügt der Satz am Anfang des Dokuments „Das Widerrufsrecht gilt nur für Verbraucher. Wir zeigen die Belehrung und das Muster-Widerrufsformular trotzdem allen, die eine Funktion bestellen.“, damit Unternehmern kein vertragliches Widerrufsrecht entsteht und die Haken für sie nichts Falsches sagen? + +**Fundstelle im Text.** Abschnitt 3.1 (Haken 1 bis 4), Abschnitt 3.2 (Einleitung des Dokuments und Belehrung), Leistungsbeschreibung Nr. 10 Abs. 1 („Verbraucher können die Bestellung der Einrichtung nach der Widerrufsbelehrung widerrufen“). + +**Unsere Annahme.** Ja. Verbraucher haben das gesetzliche Widerrufsrecht, die Haken wirken bei ihnen; Unternehmern räumt die Einleitung kein Widerrufsrecht ein, die Haken schaden ihnen nicht. Die Abwicklung eines Widerrufs ist Handarbeit des Teams (Meldung T09); ob ein Unternehmer erstattet bekommt, entscheidet das Team im Einzelfall. + +**Alternative.** Vor den Haken fragen, ob als Verbraucher oder als Unternehmer bestellt wird (Haken 3 und Belehrung nur für Verbraucher), oder den Terminplaner nur an Unternehmer verkaufen (Bestätigung nach § 14 BGB) und die Belehrung als freiwillige Zusage geben. + +### Q2: Beginn der Widerrufsfrist + +**Frage.** Wann beginnt die Widerrufsfrist der Bestellung? Wir rechnen ab dem Zeitpunkt des Klicks auf „Zahlungspflichtig bestellen · 149 €“ (gespeichert im Nachweis); das Fristende wird nach § 193 BGB auf einen Werktag verschoben. Die Belehrung steht vor dem Klick auf der Bestellseite, auf einem dauerhaften Datenträger (PDF-Anhang der Bestellbestätigung K15) erhält der Kunde sie erst nach dem Zahlungseingang. Beginnt die Frist mit dem Klick (Annahme durch WEBklar), erst mit dem Zahlungseingang (bei SEPA-Lastschrift Tage später) oder erst mit dem Zugang von K15 (§ 356 Abs. 3 BGB)? Gilt dasselbe für die Gäste (Vertragsschluss bei verbindlicher Buchung mit der Bestätigung per Link, bei einer Anfrage mit der Annahme durch den Betrieb)? + +**Fundstelle im Text.** Abschnitt 3.1 (Knopf), Abschnitt 3.2 („ab dem Tag des Vertragsabschlusses“), Abschnitt 3.9 (G02), Abschnitt 2.4; Programm: `withdrawFeature` rechnet mit dem Zeitpunkt des Klicks aus dem Nachweis (ersatzweise mit dem Bestellzeitpunkt), `withdrawalDeadline` mit 14 Tagen. + +**Unsere Annahme.** Der Klick ist der Vertragsschluss: Mit ihm löst der Kunde die Zahlung aus; danach gibt es keine gesonderte Annahmeerklärung von WEBklar. WEBklar kann die Einrichtung nur aus den Gründen von Leistungsbeschreibung Nr. 4 Abs. 3 (Gesundheitsbereich, Q11) ablehnen oder abschalten. Die Belehrung ist vor dem Klick lesbar; K15 bestätigt sie mit den PDFs auf einem dauerhaften Datenträger. + +**Alternative.** Die Frist ab Zahlungseingang oder ab Zugang von K15 rechnen und die Belehrung entsprechend formulieren („ab dem Tag, an dem Sie diese Bestellbestätigung erhalten“). + +### Q3: Zustimmungssatz: Erlöschen, Wertersatz, Kenntnis, dauerhafter Datenträger + +**Frage.** Genügt Haken 3 („Ich verlange ausdrücklich, dass WEBklar vor Ablauf der Widerrufsfrist mit der Einrichtung des Terminplaners beginnt. Mir ist bekannt, dass mein Widerrufsrecht mit der vollständigen Erbringung der Einrichtung erlischt (§ 356 Abs. 5 Nr. 2 BGB). Widerrufe ich … schulde ich einen angemessenen Betrag … (§ 357a Abs. 2 BGB).“)? Bitte prüfen: (a) die Zählung der Norm für das Erlöschen bei Dienstleistungen (§ 356 Abs. 5 Nr. 2 BGB) und für den Wertersatz (§ 357a Abs. 2 BGB); (b) ob das ausdrückliche Verlangen und die Bestätigung der Kenntnis im selben Haken stehen dürfen; (c) ob der Wertersatz-Hinweis im Haken ausreicht oder auch in die Belehrung gehört; (d) ob K15 mit den Texten der Zustimmung, dem Zeitpunkt und den PDFs die nötige Bestätigung auf einem dauerhaften Datenträger ist (die gerenderte Mail folgt als Anlage C nach der Mail-Überarbeitung des Inhabers). + +**Fundstelle im Text.** Abschnitt 3.1 (Haken 3), Abschnitt 3.2 („Hinweise zum Widerruf“), Abschnitt 3.3 (Haken der News), Leistungsbeschreibung Nr. 10 Abs. 2; Programm: `termineConsent` (Register), `onFeaturePaid` (K15, Block „Zustimmungen“ und Anhänge). + +**Unsere Annahme.** Der Haken erfüllt beides: Verlangen, Kenntnis vom Erlöschen und Hinweis auf den Wertersatz in einem Satz mit Zeitpunkt im Nachweis; K15 gibt den Satz zusammen mit der Belehrung auf einem dauerhaften Datenträger wieder. Die Zählung entspricht der Fassung auf gesetze-im-internet.de vom 2026-10-05. + +**Alternative.** Zwei Haken (Verlangen und Kenntnis getrennt); die Bestätigung als eigener, ausdrücklich so benannter Absatz in K15. + +### Q4: News-Altkunden + +**Frage.** Seit 2026-09-30 verkauft WEBklar die News-Pinnwand (30 € einmalig) über denselben Bestellweg wie jetzt den Terminplaner. Bis zur Korrektur sah die Bestellung so aus („3a0b008“ ist die Kennung des Programmstands vor der Korrektur). Alte Fassung (Stand 3a0b008), der Haken: + +> Ich verlange ausdrücklich, dass WEBklar vor Ablauf der Widerrufsfrist mit dem Einbau beginnt. Mir ist bekannt, dass mein Widerrufsrecht erlischt, sobald der Einbau vollständig erbracht ist (§ 356 Abs. 4 BGB). + +Alte Fassung (Stand 3a0b008), Leistungsbeschreibung der News-Pinnwand, Abschnitt 6 „Widerrufsrecht“ (nur diese zwei Absätze, keine gesetzliche Musterbelehrung, kein Muster-Widerrufsformular): + +> 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 project.webklar.com/widerrufen.html. +> +> 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. + +Alte Fassung (Stand 3a0b008), Abschnitt 2 derselben Leistungsbeschreibung zum Preis: + +> 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. + +Die Bestellbestätigung K15 enthielt nach unserem Stand weder eine Belehrung noch Anhänge. Der Haken zitierte also § 356 Abs. 4 BGB und enthielt keinen Hinweis auf den Wertersatz (§ 357a Abs. 2 BGB), den die Leistungsbeschreibung nur allgemein nannte; der Preis war „inklusive Umsatzsteuer“ angegeben (richtig ist § 19 UStG, Q19). Lief bei diesen News-Altkunden, soweit sie Verbraucher sind, die Widerrufsfrist nach § 356 Abs. 3 BGB nicht an? Wenn ja: Sollen wir die Belehrung per Mail nachholen (Form, Inhalt, Zeitpunkt, Hinweis zum Wertersatz), oder genügt es, nur künftig die neuen Texte zu verwenden? Was gilt für bereits eingebaute und freigeschaltete News-Pinnwände (Erlöschen des Widerrufsrechts, Wertersatz)? Die Zahl der betroffenen Fälle liefert der Inhaber vor der Übergabe: [Anzahl der Bestellungen seit 2026-09-30 mit dem alten Text]. + +**Fundstelle im Text.** Abschnitt 3.3 (neuer und früherer Haken), Abschnitt 3.2 (Belehrung der Funktionen, die die News künftig ebenfalls nutzt); Programm: `config.features.news` (neuer Haken; bestehende Bestellungen behalten den damals gespeicherten Text und die Fassung). Die alten Texte stammen aus dem Programmstand 3a0b008 (`server/config.js`, `public/vertragsunterlagen/funktionen-leistungsbeschreibung.html`). Die Leistungsbeschreibung der News-Pinnwand vom 30.09.2026 liegt unverändert unter `public/vertragsunterlagen/funktionen-leistungsbeschreibung-2026-09-30.html` (unveränderte Kopie des Standes 3a0b008, durch eine Prüfsumme gesichert), weil Bestellungen seit dem 30.09.2026 auf ihr beruhen; die geltende Datei (Stand 05.10.2026) verweist am Anfang auf sie. + +**Unsere Annahme.** Nach unserer Lesart (kein Rechtsurteil) kann die Frist bei fehlender ordnungsgemäßer Belehrung nach § 356 Abs. 3 BGB nicht angelaufen sein. Der Inhaber entscheidet nach Ihrem Befund: Belehrung nur nachholen, wenn die Frist danach nicht lief; sonst gelten die neuen Texte nur für künftige Bestellungen. + +**Alternative.** Allen News-Altkunden vorsorglich die Belehrung samt Muster-Formular per Mail schicken (mit dem Hinweis, dass die Frist damit beginnt), unabhängig vom Befund; oder, wenn die Kanzlei keinen Handlungsbedarf sieht, nichts tun. + +### Q5: Wertersatz: Anteil am Gesamtpreis oder Stundenaufwand + +**Frage.** Die Muster-Belehrung verlangt beim Widerruf einen angemessenen Betrag, der dem Anteil der bis zur Unterrichtung erbrachten Dienstleistungen im Vergleich zum Gesamtumfang entspricht (§ 357a Abs. 2 BGB). Unsere „Hinweise zum Widerruf“ sagen: „Der Betrag bemisst sich als Anteil am Gesamtpreis; Maß ist der im Arbeitsblatt erfasste Aufwand für die Einrichtung.“ Intern war ursprünglich eine Berechnung „nach Stundenaufwand“ vorgesehen. Ist diese Berechnung (Anteil an 149 € nach dem erfassten Aufwand im Verhältnis zum Gesamtaufwand der Einrichtung dieses Projekts) zulässig, oder muss der Gesamtumfang vorab festgelegt werden, damit der Anteil bestimmbar ist? + +**Fundstelle im Text.** Abschnitt 3.1 (Haken 3), Abschnitt 3.2 (Muster-Belehrung, Absatz „Haben Sie verlangt …“, und „Hinweise zum Widerruf“, dritter Punkt), Leistungsbeschreibung Nr. 10 Abs. 2; Mails K04 und T09; Programm `withdrawFeature`. + +**Unsere Annahme.** Anteilig am Preis (nicht Stundensatz mal Zeit). Das Maß der erbrachten Leistung ist der im Arbeitsblatt des Einrichtungs-Tickets erfasste Aufwand, der Gesamtumfang der Aufwand der Einrichtung dieses Projekts nach dem Ticket. Eine feste Gesamtstundenzahl steht nicht im Vertrag. + +**Alternative.** Den Gesamtumfang in der Leistungsbeschreibung festlegen (zum Beispiel feste Schritte oder eine Stundenzahl) und den Wertersatz nach erledigten Schritten bemessen; oder bei Widerruf nach Einbaubeginn pauschal einen festen Anteil ansetzen. + +### Q6: Volle Erstattung, wenn das Abo vor der Freischaltung endet + +**Frage.** Leistungsbeschreibung Nr. 8 Abs. 5 lautet: „Endet das Wartungs-Abo, bevor WEBklar den Terminplaner freigeschaltet hat, erstattet WEBklar den Einrichtungspreis vollständig.“ Ist die Klausel wirksam und klar (§ 307 BGB), auch wenn WEBklar den Einbau schon begonnen oder fertiggestellt hat und auch wenn der Kunde das Abo selbst beendet? Außerdem: Nr. 8 Abs. 4 sagt, dass WEBklar den Terminplaner bei einem neuen Wartungs-Abo innerhalb der 30 Tage wieder einschaltet. Das Programm tut das nur für den Abschaltgrund „Abo beendet“ (`abo_ende`, nach Freischaltung), nie für `abo_ende_vor_einrichtung`: Nach der Erstattung bleibt der Terminplaner aus; ob und wie er wieder eingerichtet wird, entscheidet das Team im Einzelfall. Muss der Text das sagen? + +**Fundstelle im Text.** Abschnitt 3.4 (Nr. 8 Abs. 4 bis 6), Abschnitt 2.3 Punkt 4; Programm: `gleicheAboAb` (Grund `abo_ende_vor_einrichtung`, Meldung T19 an das Team, keine automatische Rückkehr), Mail K22 Block „vor_einrichtung“. + +**Unsere Annahme.** Die Klausel begünstigt den Kunden und ist unproblematisch. Die Rückkehr gilt nur für den Fall nach der Freischaltung; für den Erstattungsfall ist sie nicht gewollt, weil die Erstattung voll geschuldet bleibt. + +**Alternative.** Die Erstattung auf den Fall beschränken, dass WEBklar noch nicht mit dem Einbau begonnen hat (sonst Wertersatz); Nr. 8 Abs. 4 um den Satz ergänzen, dass die Rückkehr nicht gilt, wenn der Einrichtungspreis erstattet wurde. + +### Q7: Ende mit dem Abo, auch bei Wechsel und Widerruf; Ausnahme für Pilot und Kulanz + +**Frage.** (a) Leistungsbeschreibung Nr. 8 Abs. 1 lässt den Terminplaner mit dem Wartungs-Abo enden, auch beim Wechsel ins Hosting-Abo und beim Widerruf des Abschlusses oder eines Wechsels ins Wartungs-Abo. Ist diese Kopplung zulässig, obwohl der Kunde die Einrichtung einmalig bezahlt hat und sie ein eigener Vertrag ist? Wie verhält sich der Widerruf des Abos zum Einrichtungsvertrag, insbesondere wenn der Terminplaner schon freigeschaltet ist und deshalb nichts erstattet wird? (b) Ausnahme: Terminplaner, die das Team ohne Bestellung freigeschaltet hat (Pilot, Kulanz; im Programm `unlockedByPurchase` nicht wahr), nimmt der Abgleich aus und zählt sie im Tagesbericht als „ausgenommen“; sie laufen weiter, bis WEBklar sie abschaltet. Genügt der Satz „WEBklar schaltet ihn spätestens am folgenden Tag ab“ trotzdem, oder braucht es eine Klausel, die diese Kulanzfälle nennt? (c) Der Abgleich schaltet bei Lesefehlern nichts ab und versucht es am nächsten Tag; ein Abo im Status „Zahlungsverzug“ gilt als laufend. Ist „spätestens am folgenden Tag“ dann zu streng gesagt? + +**Fundstelle im Text.** Abschnitt 3.4 (Nr. 8 Abs. 1 und 6, Nr. 10), Abschnitt 3.1 (Haken 4: „der Terminplaner endet mit dem Wartungs-Abo“), Abschnitt 2.3, Abschnitt 2.1 (Freischaltung ohne Bestellung); Programm: `gleicheAboAb`, `endeTermineFuerKunde`. + +**Unsere Annahme.** Die Kopplung ist zulässig, weil der Terminplaner nur mit einem Wartungs-Abo betrieben wird und der Kunde das in Haken 4 liest. Kulanzfälle sind keine Verträge über die Einrichtung; ihre Ausnahme braucht keine Klausel. „Spätestens am folgenden Tag“ beschreibt den Normalfall. + +**Alternative.** Nr. 8 Abs. 1 auf „in der Regel am folgenden Tag“ abschwächen; eine Kulanzklausel („Terminplaner, die WEBklar ohne Bestellung freigeschaltet hat, schaltet WEBklar nach eigenem Ermessen ab“) aufnehmen; beim Widerruf des Abos nach Freischaltung eine anteilige Erstattung prüfen. + +### Q8: Haftung für verpasste Termine; Erstattung bei Einstellung + +**Frage.** (a) Leistungsbeschreibung Nr. 7 Abs. 3 (Abschnitt 3.4) beschränkt die Haftung für entgangenen Gewinn und Folgeschäden aus verpassten Terminen auf den Rahmen von § 13 Abs. 1 und 2 der Abo-Bedingungen (unbeschränkt bei Vorsatz, grober Fahrlässigkeit, Verletzung von Leben, Körper oder Gesundheit; bei leicht fahrlässiger Verletzung wesentlicher Pflichten begrenzt auf den vertragstypischen, vorhersehbaren Schaden; im Übrigen ausgeschlossen). Ist die Klausel gegenüber Verbrauchern mit § 309 Nr. 7 BGB vereinbar, und ist die Verweisung hinreichend transparent? (b) Braucht es gegenüber Unternehmern einen Höchstbetrag (etwa den Jahresbetrag des Wartungs-Abos)? (c) Nr. 9 Abs. 4 schließt eine Einstellung ohne Grund aus. Wann WEBklar den Terminplaner beenden oder aussetzen darf, regeln Nr. 9 Abs. 2 und 3: + +> (2) WEBklar darf den Terminplaner für eine Website mit einer Frist von vier Wochen beenden oder für alle Kunden mit einer Frist von drei Monaten einstellen, wenn +> - 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 Kunde einer dafür nötigen neuen Fassung nicht zustimmt (AVV Terminplaner Nummer 12), oder +> - eine Sicherheitslücke sich nicht anders beheben lässt. +> +> 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. +> +> (3) Ist der Weiterbetrieb aus einem Grund nach Absatz 2 schon vor Ablauf der Frist nicht zulässig, darf WEBklar den Terminplaner ohne Vorankündigung aussetzen, solange das nötig ist. WEBklar informiert den Kunden darüber unverzüglich in Textform mit dem Grund und nimmt den Betrieb wieder auf, sobald das zulässig und technisch möglich ist. + +Zur Erstattung des Einrichtungspreises, wenn WEBklar den Terminplaner danach beendet, schweigt der Text. Braucht es eine (anteilige) Erstattung? + +**Fundstelle im Text.** Abschnitt 3.4 (Nr. 7 und Nr. 9 Abs. 4), Leistungsbeschreibung Nr. 9 Abs. 2 und 3 (oben zitiert), Abo-Bedingungen § 13 (`public/abo-bedingungen.html`). + +**Unsere Annahme.** Die Verweisung auf § 13 genügt; ein Höchstbetrag ist nicht nötig, weil § 13 Abs. 2 die Haftung schon auf den vorhersehbaren Schaden begrenzt. Bei Einstellung aus den Gründen von Nr. 9 Abs. 2 erstattet WEBklar nichts, weil der Terminplaner bis dahin lief; die Rechte aus § 327r BGB bleiben. + +**Alternative.** Einen Höchstbetrag gegenüber Unternehmern festlegen; bei Einstellung durch WEBklar den Einrichtungspreis anteilig für die Restlaufzeit erstatten. + +### Q9: 30 Tage nach dem Abschalten: Anker, Rückkehr, Sicherungen + +**Frage.** Der Terminplaner endet mit dem Wartungs-Abo; WEBklar schaltet ihn spätestens am folgenden Tag ab und löscht alle Termindaten und Einstellungen 30 Tage nach dem Abschalten. Bis dahin kann der Kunde die Termine im Kundenportal ansehen und als CSV-Datei exportieren; beginnt ein neues Wartungs-Abo, schaltet WEBklar ohne Kosten mit den vorhandenen Daten wieder ein (Rückkehr, nur beim Abschaltgrund „Abo beendet“, `abo_ende`). Fragen: (a) Ist der Anker (Zeitpunkt des Abschaltens, `disabledAt`, nicht der Tag des Abo-Endes) und die Frist von 30 Tagen mit Art. 28 Abs. 3 lit. g DSGVO vereinbar? (b) Darf die Rückkehr ohne neue Zustimmung zur Fassung automatisch erfolgen? (c) Gelöschte Daten bleiben in der täglichen Vollsicherung bis zu sieben Tage (Rückspielen holt der Löschlauf nach): genügt das? (d) Der tägliche Löschlauf löscht frühestens nach Ablauf der jeweiligen Frist, nicht sekundengenau; „48 Stunden“ und „30 Tage“ sind frühestmögliche Zeitpunkte (H4, A3). (e) Terminanfragen, die der Kunde bis zum angefragten Terminbeginn nicht beantwortet, gelten ab dann als abgelehnt, und die 30 Tage laufen ab diesem Zeitpunkt (AVV Nr. 9 Abs. 2). (f) Der Satz im Datenschutz-Absatz der Kunden-Website („Wird die Online-Terminbuchung abgeschaltet, zum Beispiel weil unser Vertrag mit WEBklar endet, werden alle Termindaten 30 Tage nach dem Abschalten gelöscht.“) und der Satz in Abschnitt 6c stimmen mit dem Programm überein, seit der tägliche Abgleich das Abschalten übernimmt. (g) Das Einrichtungs-Ticket, in dem das Team die Einrichtung als Arbeitsnachweis festhält, bleibt nach der Löschung mit einer Kurzfassung der Einstellungen bestehen (Betriebsangaben, Namen der Leistungen, die ersten acht Namen der Personen oder Plätze, Wünsche des Kunden zum Einbau; keine Daten von Gästen). Leistungsbeschreibung Nr. 8 Abs. 3 und AVV Nr. 9 Abs. 6 sagen das; die Datenschutzerklärung (Abschnitt 6) nennt die Arbeitsnachweise und die Aufbewahrung bis zum Ablauf der Verjährungsfrist. Ist das neben der Löschpflicht des Art. 28 Abs. 3 lit. g DSGVO zulässig, auch wenn der Kunde die Löschung aller Daten anweist (AVV Nr. 9 Abs. 6)? + +**Fundstelle im Text.** Abschnitt 3.4 (Nr. 8 Abs. 3 und 4), Abschnitt 3.5 (AVV Nr. 9, besonders Abs. 2, 6 und 8), Abschnitt 2.3; frühere Punkte H4, A3, C4 (Sicherungen), C7 (Satz in 6c); Mail K22 und Datenschutzerklärung 6c (Hausrunde); Programm: `LOESCHFRIST_TAGE`, `loescheAbgeschaltete`, `gleicheAboAb`; Einrichtungs-Ticket: `termineTicketDetails`. + +**Unsere Annahme.** Ja. 30 Tage ab dem Abschalten sind angemessen, weil der Kunde in dieser Zeit Termine ansehen und exportieren kann und eine Rückkehr kostenlos ist. Die Rückkehr stellt den früheren Stand wieder her; die zugestimmte Fassung gilt weiter, eine neue Fassung braucht ihre Zustimmung nach AVV Nr. 12. Der Sicherungsüberhang von sieben Tagen folgt aus der täglichen Vollsicherung bei Hetzner (letzte sieben Tagessicherungen) und steht offen im Vertrag. + +**Alternative.** Das Modell der Website-Analyse: Löschung am 61. Tag nach dem Ende, Kopie der Daten auf Verlangen bis zum 46. Tag (AVV Website-Analyse Nr. 9 Abs. 2); oder 30 Tage ab dem Tag des Abo-Endes statt ab dem Abschalten. + +### Q10: AVV Terminplaner: Aufbau, Hetzner, Drittland, Daten, Weisungen + +**Frage.** Bitte den AVV (Anlage A) prüfen; besonders: (a) der Abschluss durch Haken bei der Bestellung (Textform; Art. 28 Abs. 9 DSGVO) mit gespeicherter Fassung, Zeitpunkt, Konto und Person, und durch Zustimmung in Textform bei Freischaltung durch das Team (Q21); (b) die Wahl eines eigenen Vertrags (Geschwister des AVV der Website-Analyse, Vorrang vor Anlage 1 der Abo-Bedingungen) statt eines Rahmen-AVV oder eines Zusatzes, damit Kunden der Website-Analyse nicht neu zustimmen müssen; (c) Nr. 7: Hetzner Online GmbH, Rechenzentrum Falkenstein (Server und Sicherung) und Nürnberg (SMTP-Dienst des Webhostings) und der Satz „Eine Übermittlung in Drittländer findet nicht statt“, auch wenn ein Kunde den geheimen Kalender-Link in einem Kalenderdienst eines Anbieters wie Google oder Apple abonniert (das Abonnement richtet der Kunde ein, WEBklar übermittelt dorthin nichts) (A2); (d) Nr. 7 Abs. 4: Stripe ist kein Unterauftragsverarbeiter, weil keine Daten von Gästen oder Beschäftigten an Stripe gehen; (e) Aufruf des Widgets: IP-Adresse und Browser-Kennzeichen erhält der Server technisch bei jedem Abruf, Server-Protokolle 14 Tage (Art. 6 Abs. 1 lit. f DSGVO) (A1); im Knopfmodus lädt das Widget die Konfiguration schon beim Laden der Seite (A6, C8), vom selben Server wie das Skript; (f) Beschäftigtendaten: Namen, Zeiten und der geheime Kalender-Link; der Kunde informiert seine Beschäftigten (C3); (g) Nr. 3 Abs. 1: Absagegrund und Vermerke (Freitext, auch mit dem Namen aus einer Widerrufserklärung) sowie Preis, Zahlart, Zahlungsstatus und die Angabe, ob als Verbraucher gebucht wurde; Nr. 2 Abs. 3: Zählungen ohne Personenbezug, die WEBklar für den sicheren Betrieb braucht, sind keine eigenen Zwecke; (h) Nr. 4 Abs. 2 und Nr. 9 Abs. 6: Löschung auf Weisung in Textform „unverzüglich“, ohne feste Frist. + +**Fundstelle im Text.** Abschnitt 3.5 (Vorbemerkung Abs. 1 und 2, Nr. 3 Abs. 1, Nr. 4 Abs. 2, Nr. 7, Nr. 9), Anlage A vollständig mit der Anlage der technischen und organisatorischen Maßnahmen; frühere Punkte A1, A2, A6, C3, C8; Programm: `baueNachweis`, `datenschutzAbsatz`. + +**Unsere Annahme.** Der AVV ist vollständig und passt zum Programm (Datenarten, Löschfristen, Unterauftragsverarbeiter sind durch Tests an den Code gebunden). Eine Drittlandübermittlung durch WEBklar findet nicht statt; das Kalender-Abonnement liegt in der Verantwortung des Kunden. „Unverzüglich“ genügt für die Löschung auf Weisung. + +**Alternative.** Ein Rahmen-AVV mit einer Anlage je Funktion (Abschnitt 6.2); im AVV einen Hinweis auf das Kalender-Abonnement bei Drittanbietern aufnehmen; für die Löschung auf Weisung eine feste Frist nennen (zum Beispiel 14 Tage). + +### Q11: Gesundheits-Ausschluss + +**Frage.** Der Terminplaner ist nicht für den Gesundheitsbereich bestimmt (Art. 9 DSGVO). Schutzmaßnahmen: die Branchenfrage im Fragebogen (der Gesundheitsbereich ist wählbar, die Bestellung dann aber gesperrt), Haken 2 mit Aufzählung (Arzt-, Zahnarzt-, Psychotherapie-, Heilpraktiker- oder Physiotherapiepraxis), eine weiche Stichwortprüfung über Betriebsname, Leistungen und Felder, die das Team warnt, das Recht in Leistungsbeschreibung Nr. 4 Abs. 3, die Einrichtung abzulehnen oder den Terminplaner dort abzuschalten, der Hinweis „Bitte keine Gesundheitsangaben“ über jedem Freitextfeld und im Datenschutz-Hinweis (A5), die Pflicht des Kunden in AVV Nr. 11 Abs. 4. Reicht das, damit WEBklar als Auftragsverarbeiter keine besonderen Kategorien verarbeitet? Und: Leistungsbeschreibung Nr. 4 Abs. 3 sagt nichts zur Erstattung, wenn WEBklar die Einrichtung wegen des Gesundheitsbereichs ablehnt oder abschaltet; muss der Text das regeln? + +**Fundstelle im Text.** Abschnitt 3.1 (Haken 2), Abschnitt 3.4 (Nr. 4 Abs. 3), Abschnitt 3.7 (Hinweis), Anlage A Nr. 3 Abs. 3 und Nr. 11 Abs. 4; früherer Punkt A5; Programm: `BRANCHEN`, `gesundheitsWarnungen`, `bestellpruefung`. + +**Unsere Annahme.** Die Kombination aus Frage, Haken, Warnung und Ablehnungsrecht genügt; die Aufzählung im Haken ist beispielhaft. Lehnt WEBklar die Einrichtung ab, erstattet WEBklar den Preis aus Kulanz (nach Widerruf oder Kulanz, kein Satz im Text). + +**Alternative.** Eine ausdrückliche Erstattungsklausel für die Ablehnung; die Aufzählung im Haken durch „Heilkunde und Gesundheitsversorgung“ ersetzen; eine zweite Bestätigung im Fragebogen. + +### Q12: Gäste-Belehrung: Abweichungen vom Muster und Gesetzlichkeitsfiktion + +**Frage.** Die Belehrung für Gäste (Abschnitt 3.6) beruht auf dem Muster der Anlage 1 zu Art. 246a § 1 Abs. 2 Satz 2 EGBGB, angepasst für einen Dienstleistungsvertrag. Genügt sie für die Gesetzlichkeitsfiktion, obwohl sie abweicht? (a) Die Klammer zu den Lieferkosten im Absatz „Folgen des Widerrufs“ bleibt stehen, weil der Gestaltungshinweis keine Streichung bei Dienstleistungen vorsieht (B1); dasselbe gilt für die Belehrung der Funktionen (Abschnitt 3.2). (b) Die Widerrufsfunktion nennt statt einer Internetadresse einen „anderen geeigneten Hinweis“: die Schaltfläche „Vertrag widerrufen“ im Terminbereich und den Link „Termin verwalten“ in der Bestätigungs-E-Mail; die Adresse der Seite fehlt (B5). (c) „… dass die Dienstleistungen … beginnen soll“ steht im Singular des Gesetzestexts (B2). (d) Das Widget zeigt die Belehrung aufklappbar in der Zusammenfassung vor dem Knopf, die Bestätigungs-E-Mail enthält sie als Text (H1). (e) Die Fiktion gilt nur, wenn das Muster inhaltlich und gestalterisch dem Gesetz entspricht; Zeilenumbrüche, Überschriften und die Aufzählung im Formular sind unsere Darstellung (B6). + +**Fundstelle im Text.** Abschnitt 3.6, Abschnitt 3.2 (Lieferkosten-Klammer und Widerrufsfunktion mit Adresse der Widerrufsseite bei den Funktionen), Abschnitt 3.9, Abschnitt 3.10 („Vertrag widerrufen“); frühere Punkte H1, B1, B2, B5, B6; Programm: `widerrufsbelehrung`, Muster in `docs/rechtliches/quellen/`. + +**Unsere Annahme.** Ja: Die Abweichungen sind die Ausfüllung der Lücken, die das Muster vorsieht, oder der Gesetzestext selbst; die aufklappbare Darstellung ist zulässig, weil sie vor dem Absenden vollständig erreichbar ist. + +**Alternative.** Die Klammer zu den Lieferkosten streichen; die Widerrufsfunktion zusätzlich mit der Adresse der Seite des Betriebs nennen; die Belehrung ohne Aufklappen offen anzeigen; „beginnen sollen“ im Plural. + +### Q13: Gäste: Haken zum vorzeitigen Beginn und Wertersatz + +**Frage.** Der Haken „Vorzeitiger Beginn“ (Abschnitt 3.7) wird nur Verbrauchern bei Leistungen mit Preis gezeigt, wenn der Termin vor Ablauf der Widerrufsfrist beginnt; bei Anfragen mit Preis immer, weil der Tag der Annahme nicht feststeht. Die Frist rechnen wir mit 14 Tagen ab dem Tag nach dem Vertragsschluss im Berliner Kalender (§§ 187, 188, 193 BGB), gerechnet ab Buchungszeitpunkt plus Haltezeit. (a) Genügt der Haken (Verlangen, Kenntnis, Wertersatz) in dieser Form? (b) Der Absatz zum Wertersatz steht immer in der Belehrung, auch wenn der Gast keinen vorzeitigen Beginn verlangt hat, weil das Muster ihn für Dienstleistungen vorsieht (B4). Ist das unschädlich? (c) Liegt der Termin nach Ablauf der Frist, zeigen wir keinen Haken: stimmt das? + +**Fundstelle im Text.** Abschnitt 3.7 (Haken „Vorzeitiger Beginn“), Abschnitt 3.6 (Absatz „Haben Sie verlangt …“); früherer Punkt B4; Programm: `ZUSTIMMUNG_VORZEITIG`, `brauchtZustimmungVorzeitig`. + +**Unsere Annahme.** Ja zu (a) bis (c): Der Haken wirkt nur, wo ein Widerrufsrecht noch besteht und die Leistung vor Fristende beginnt; der Muster-Absatz zum Wertersatz bleibt unverändert, um die Fiktion nicht zu gefährden. + +**Alternative.** Den Haken bei jeder Buchung mit Preis zeigen; den Wertersatz-Absatz nur bei gesetztem Haken in die Belehrung aufnehmen (Abweichung vom Muster). + +### Q14: Anfrage mit Festpreis, Bestätigungslink und Seitenknopf als Bestellung + +**Frage.** Wann ist die Buchung eines Gastes eine Bestellung mit Zahlungspflicht im Sinne von § 312j Abs. 3 BGB? Unser Ablauf: Bei einer verbindlichen Buchung mit Preis trägt der Knopf im Widget die Aufschrift „Zahlungspflichtig buchen“; der Gast bestätigt dann per Link in einer E-Mail, und die Bestätigungsseite hat den Knopf „Zahlungspflichtig bestätigen“; erst damit kommt der Vertrag zustande. Bezahlt wird vor Ort. Bei einer Terminanfrage, auch mit Festpreis, heißen die Knöpfe „Termin anfragen“ und „Anfrage absenden“, der Vertrag kommt erst mit der Annahme durch den Betrieb zustande. Ohne Preis lauten die Knöpfe „Termin buchen“ und „Termin bestätigen“. Genügt das? Muss der Knopf einer Anfrage mit Festpreis ebenfalls „zahlungspflichtig“ heißen? Ist der Seitenknopf auf der Bestätigungsseite die maßgebliche Bestellung? + +**Fundstelle im Text.** Abschnitt 3.10 (Knopftexte), Abschnitt 2.2 Punkt 2; Programm: `knopfText`. + +**Unsere Annahme.** Ja: Die zahlungspflichtige Handlung ist die Bestätigung auf der Bestätigungsseite; bei einer Anfrage entsteht die Zahlungspflicht erst mit der Annahme, die der Betrieb erklärt und nicht der Gast. + +**Alternative.** Auch den Knopf einer Anfrage mit Festpreis „Zahlungspflichtig anfragen“ nennen; bei einem Preis den Preis am Knopf zeigen. + +### Q15: Einstufung als Freizeitleistung und Hinweis im Portal + +**Frage.** Im Kundenportal stuft der Betrieb selbst ein, ob seine Termine Freizeitveranstaltungen zu einem festen Termin sind (§ 312g Abs. 2 Nr. 9 BGB); kreuzt er das an, zeigt der Terminplaner keine Widerrufsbelehrung, kein Formular und keine Widerrufsfunktion, und die Bestätigung nennt den Satz aus Abschnitt 3.9. Der Hinweis dazu im Portal lautet: + +> Nur ankreuzen, wenn deine Termine Freizeitveranstaltungen zu einem festen Termin sind (§ 312g Abs. 2 Nr. 9 BGB), zum Beispiel ein einzelner Tanzabend, ein Kurs oder ein Escape Room. Nach unserer Einschätzung gilt bei Friseur, Kosmetik, Nagel- und Fußpflege, Tattoo, Fotoshootings, Coaching und Beratung in der Regel ein Widerrufsrecht; bei Massage und Wellness ist die Einordnung unklar, im Zweifel gilt ein Widerrufsrecht. Kreuzt du es an, zeigt der Terminplaner keine Widerrufsbelehrung. Kreuzt du es zu Unrecht an, können deine Gäste noch bis zu 12 Monate und 14 Tage widerrufen. Das ist keine Rechtsberatung: Im Zweifel nicht ankreuzen oder dich rechtlich beraten lassen. + +Ist diese Einschätzung vertretbar, vor allem für Fotoshootings, Coaching, Beratung, Tattoo und Massage? Genügt „Das ist keine Rechtsberatung“, oder haftet WEBklar, wenn ein Betrieb zu Unrecht ankreuzt und Gäste noch bis zu 12 Monate und 14 Tage widerrufen können? Ein zweiter Hinweis im Portal (`WIDERRUF_HINWEIS`) erklärt dem Betrieb, dass Privatpersonen eine Buchung 14 Tage nach Vertragsschluss widerrufen können (bei Anfragen zählt die Annahme), dass das Widerrufsrecht seiner Stornoregel vorgeht, dass bei vorzeitigem Beginn Wertersatz geschuldet sein kann (§ 357a Abs. 2 BGB) und dass der Terminplaner Belehrung und Knopf „Vertrag widerrufen“ automatisch zeigt. + +**Fundstelle im Text.** Abschnitt 3.9 (Satz „kein Widerrufsrecht“), Leistungsbeschreibung Nr. 4 Abs. 8 und Nr. 11 Abs. 1 (die Einstufung trägt der Kunde); Programm: `FREIZEIT_HINWEIS`, `WIDERRUF_HINWEIS`, `zeigtWiderruf`. + +**Unsere Annahme.** Friseur, Kosmetik, Nagel- und Fußpflege, Tattoo, Fotoshootings, Coaching und Beratung fallen in der Regel nicht unter die Ausnahme; bei Massage und Wellness ist die Einordnung unklar, im Zweifel besteht ein Widerrufsrecht. Der Hinweis stellt die Einstufung klar dem Betrieb zu. + +**Alternative.** Die Einstufung nicht dem Betrieb überlassen: Der Terminplaner zeigt die Belehrung immer, die Freizeit-Ausnahme gibt es nicht; oder die Beispiele im Hinweis streichen. + +### Q16: Storno-Muster (50 Prozent, 24 Stunden) und Wegfall bei kostenlosen Leistungen + +**Frage.** Die Vorlagen schlagen dem Betrieb Stornobedingungen vor, zum Beispiel für Salon, Kosmetik und Beratung: + +> Sie können Ihren Termin bis 24 Stunden vorher kostenlos absagen oder verschieben (Link in der Bestätigungsmail). Danach oder bei Nichterscheinen berechnen wir für den freigehaltenen Termin 50 % des Preises. Dem Kunden bleibt der Nachweis vorbehalten, dass uns kein oder ein geringerer Aufwand entstanden ist. Ihr gesetzliches Widerrufsrecht bleibt unberührt. + +(Bei Tattoo und Fotografie 48 Stunden.) Im Portal steht neben dem Textfeld der Hinweis: + +> Das ist ein Muster, kein Rechtstext. Bitte prüfe es und passe es an deine Bedingungen an. + +(a) Hält das Muster einer Kontrolle nach § 307 BGB (und § 309 Nr. 5 BGB gegenüber Verbrauchern) stand, soweit es als Vorschlag für den Betrieb dient? (b) Hat keine Leistung einer Vorlage einen Preis (Vorlagen „handwerk“ und „sonstiges“), gibt es keinen Vorschlag. Bietet ein Betrieb einzelne kostenlose Leistungen neben Leistungen mit Preis an, bleibt sein Stornotext stehen, aber der Pflicht-Haken „Ich habe die Stornobedingungen gelesen.“ entfällt bei den kostenlosen Leistungen. Genügt das? (c) Die Bestätigungs-E-Mail G02 zeigt den Stornotext immer, wenn der Betrieb einen hinterlegt hat; ein Betrieb kann auch dann einen Text eingeben, wenn er kostenlose Leistungen anbietet, und dann steht er auch in der Bestätigung kostenloser Buchungen. Der Satz „Absagen richten sich nach den Stornobedingungen“ im Block „kein_widerruf“ (Abschnitt 3.9) steht dagegen bei jeder Buchung, die der Betrieb als Freizeit-Dienstleistung einstuft, also auch bei kostenlosen Buchungen und auch ohne hinterlegten Stornotext. Die Einbauanleitung für Mitarbeiter nennt den Stornotext unabhängig vom Preis. Ist das bei kostenlosen Leistungen irreführend? Wir wollen für G02 einen neutralen Satz festlegen (Checkliste, Abschnitt 7.4). + +**Fundstelle im Text.** Abschnitt 3.7 (Haken „Stornobedingungen“), Abschnitt 3.9 (G02); Programm: `stornoText`, `STORNO_PROZENT`, `pruefeEingabe`, `STORNO_MUSTER_HINWEIS`. + +**Unsere Annahme.** Der Vorschlag ist ein Muster mit Nachweisvorbehalt und dem Hinweis auf das Widerrufsrecht; der Betrieb prüft und passt ihn an. Bei kostenlosen Leistungen gibt es nichts zu stornieren, der Haken ist dort nicht nötig. Der Satz in G02 wird für Buchungen ohne Preis neutral gefasst. + +**Alternative.** Keinen Stornovorschlag mitliefern; oder 50 Prozent durch eine feste Gebühr oder einen Staffelsatz ersetzen. + +### Q17: Datenschutz- und Impressum-Link; Widerrufssatz am Erinnerungshaken + +**Frage.** (a) Der Datenschutz-Hinweis am Buchungsformular verweist auf die Datenschutzerklärung des Betriebs; das Widget ließe den Link sonst still weg. Deshalb verlangt das Bestell-Gate vor der Bestellung die Adressen von Impressum und Datenschutzerklärung (H3). Ist die Pflicht ausreichend, oder müssen Links zu Datenschutzerklärung und Impressum direkt am Haken stehen? (b) Der Erinnerungshaken endet mit „Diese Einwilligung kann ich jederzeit widerrufen.“ (Art. 7 Abs. 3 DSGVO), ohne Widerrufsweg und ohne Link am Haken; die Einzelheiten stehen im Datenschutz-Absatz (H2). Genügt das? + +**Fundstelle im Text.** Abschnitt 3.7 (Haken „Erinnerung“), Abschnitt 3.8 (Datenschutz-Hinweis); frühere Punkte H2, H3; Programm: `ZUSTIMMUNG_ERINNERUNG`, `formularHinweis`, `bestellpruefung`. + +**Unsere Annahme.** Ja: Der Satz am Haken informiert vor der Einwilligung über das Recht zum Widerruf, die Einzelheiten und der Weg stehen im Hinweis und im Datenschutz-Absatz. + +**Alternative.** Den Widerrufsweg („über den Link in der E-Mail oder per Nachricht an {{E-Mail}}“) in den Haken schreiben; Links zu Datenschutzerklärung und Impressum neben den Haken setzen. + +### Q18: Datenschutz-Absatz auf der Kundenseite: Einfügen durch WEBklar im Preis + +**Frage.** Leistungsbeschreibung Nr. 4 Abs. 9: Liegt die Datenschutzerklärung in der von WEBklar betreuten Website, fügt WEBklar beim Einbau den Datenschutz-Absatz für die Online-Terminbuchung ein, das gehört zur Einrichtung und wird nicht auf das Stundenkontingent angerechnet; sonst erhält der Kunde den Absatz als Textbaustein. Der Kunde bleibt für seine Datenschutzerklärung verantwortlich; fällt WEBklar ein offensichtlicher Widerspruch auf, weist WEBklar darauf hin, eine Prüfpflicht entsteht nicht. (a) Ist das Einfügen eines Mustertextes durch WEBklar eine Rechtsdienstleistung (§ 2 RDG), oder bleibt es Technik und Mustertext (Nr. 11 Abs. 1)? (b) Reicht die Abgrenzung der Verantwortung? (c) Dieselbe Regel steht in der Leistungsbeschreibung der Website-Analyse (Nr. 4 Abs. 4); wir wollen sie für beide gleich halten. + +**Fundstelle im Text.** Abschnitt 3.4 (Nr. 4 Abs. 9, Nr. 11 Abs. 1); `docs/rechtliches/leistungsbeschreibung-website-analyse.md` Nr. 4 Abs. 4; Programm: `datenschutzAbsatz`. + +**Unsere Annahme.** Das Einfügen eines unveränderten Mustertextes mit Hinweis ist keine Rechtsdienstleistung im Einzelfall; die Verantwortung bleibt beim Kunden. + +**Alternative.** WEBklar fügt nichts ein und liefert nur den Textbaustein; oder WEBklar fügt ein und der Kunde bestätigt den Text ausdrücklich. + +### Q19: Preisangabe nach § 19 UStG; Preis am Knopf + +**Frage.** (a) Die Einrichtung steht überall sinngemäß als „149 € einmalig, ohne Umsatzsteuer gemäß § 19 UStG“, im Wortlaut so: im Haken 4 als „Gesamtpreis 149 € einmalig (ohne Umsatzsteuer gemäß § 19 UStG)“ (Abschnitt 3.1), in Leistungsbeschreibung Nr. 5 Abs. 2: + +> (2) Die Einrichtung kostet 149 € einmalig je Website-Projekt, ohne Umsatzsteuer gemäß § 19 UStG. + +und in der Bestellbestätigung K15 als Zusatz zur Preiszeile: + +> einmalig, ohne Umsatzsteuer gemäß § 19 UStG; der Terminplaner läuft, solange Dein Wartungs-Abo läuft + +Früher stand bei den News „inklusive Umsatzsteuer“ (Q4); das haben wir korrigiert. Ist die Angabe als Endpreis richtig, solange WEBklar Kleinunternehmer bleibt, und was ist bei Überschreiten der Grenze zu tun? (b) Der Knopf trägt den Preis: „Zahlungspflichtig bestellen · 149 €“ (Abschnitt 3.1). Ist der Zusatz mit § 312j Abs. 3 BGB vereinbar und nötig? + +**Fundstelle im Text.** Abschnitt 3.1 (Haken 4 und Knopf), Abschnitt 3.4 (Nr. 5); Programm: `ustHinweis`, `TERMINE_PREIS`, `termineConsent`. + +**Unsere Annahme.** WEBklar bleibt Kleinunternehmer (der Inhaber bestätigt das, Abschnitt 7.1); der Preis am Knopf schadet nicht, weil die gesetzlichen Worte „zahlungspflichtig bestellen“ unverändert davor stehen. + +**Alternative.** Knopf ohne Preis und den Preis direkt darüber zeigen; bei Überschreiten der Kleinunternehmergrenze Preise und Texte neu fassen (neues Fassungsdatum). + +### Q20: Pflichtinformationen (Art. 246a § 1 EGBGB) als Haken und in K15 + +**Frage.** Haken 4 bestätigt, dass der Besteller die Pflichtinformationen gelesen hat: Leistung (Einrichtung des Terminplaners für diese Website: ein Widget an einer Stelle, gestaltet nach dem Fragebogen, mit Einbau und Datenschutz-Absatz), Gesamtpreis, voraussichtliche Dauer, Ende mit dem Wartungs-Abo, und dass er die Widerrufsbelehrung gelesen hat. Genügen Haken 4 zusammen mit der Belehrung, den versionierten Links auf Leistungsbeschreibung und AVV und der Bestellbestätigung K15 (Zustimmungstexte und PDF-Anhänge) den Informationspflichten nach Art. 246a § 1 Abs. 1 EGBGB? Wird zwischen Klick und Zahlung eine neue Fassung veröffentlicht, schickt K15 die Unterlagen der Fassung, der der Kunde zugestimmt hat (der vereinbarten Fassung), nicht die aktuelle; fehlen sie, geht K15 ohne Anhänge und das Team sendet sie von Hand. Ist das ausreichend? Die gerenderte Mail K15 folgt als Anlage C nach der Mail-Überarbeitung des Inhabers. + +**Fundstelle im Text.** Abschnitt 3.1 (Haken 4), Abschnitt 3.4 (Nr. 5), Abschnitt 2.1 Schritt 6; Programm: `k15Rechtsunterlagen`, `onFeaturePaid`, `legalPdfAttachments`. + +**Unsere Annahme.** Ja: Die Informationen stehen vor der Bestellung im Text der Seite und in den Unterlagen; der Haken macht die Kenntnisnahme nachweisbar; K15 gibt sie auf einem dauerhaften Datenträger wieder. + +**Alternative.** Die Pflichtinformationen als eigenen Textblock auf der Bestellseite und in K15 im Wortlaut wiederholen, statt sie im Haken zu bündeln. + +### Q21: Freischaltung durch das Team mit Zustimmung in Textform; K16 mit PDFs + +**Frage.** Ohne Bestellung (Kulanz, Pilot) schaltet das Team einen Terminplaner in der Betriebsart `on` (alle Kunden mit Wartungs-Abo, Abschnitt 1) nur frei, wenn der Kunde vorher in Textform (Chat im Kundenportal oder E-Mail) zugestimmt hat; Pilotadressen sind davon ausgenommen. Das Portal speichert als Nachweis (ohne Stripe-Sitzung, IP und Browser; Konto ist der Mitarbeiter) diesen Satz, mit Fassung, Datum und Weg des Falls: + +> Der Kunde hat der Leistungsbeschreibung Terminplaner und dem Auftragsverarbeitungsvertrag Terminplaner (Fassung \) am \.\.\ per Chat im Kundenportal in Textform zugestimmt und bestätigt, dass sein Angebot nicht im Gesundheitsbereich liegt. + +Bei der Zustimmung per E-Mail steht „per E-Mail“ statt „per Chat im Kundenportal“. Für den Chat liefert das Ticketsystem dem Mitarbeiter diese Textvorlage zum Kopieren (die Adressen sind die versionierten Seiten der beiden Unterlagen): + +> Damit wir den Terminplaner für Dich einschalten können, brauchen wir Deine Zustimmung in Textform. Es gelten die Leistungsbeschreibung Terminplaner und der Auftragsverarbeitungsvertrag Terminplaner in der Fassung \: \ · \. Bitte antworte hier mit „Ich stimme zu“ und bestätige, dass Dein Angebot nicht im Gesundheitsbereich liegt. + +Danach geht K16 mit Leistungsbeschreibung und AVV als PDF an den Kunden. Genügt das? (a) Zustimmung durch eine Chat-Antwort für den AVV (Art. 28 Abs. 9 DSGVO) und die Leistungsbeschreibung; (b) der Nachweis ist der Eintrag des Mitarbeiters, die Chat-Nachricht bleibt im Portal; (c) K16 nennt die Anhänge nicht im Text und ist die Bestätigung auf einem dauerhaften Datenträger (die gerenderte Mail folgt als Anlage C nach der Mail-Überarbeitung des Inhabers); (d) für diesen Weg gibt es keine Widerrufsbelehrung (kein Kauf, kein Preis); (e) auch das Wiedereinschalten eines abgeschalteten Terminplaners, den das Team ohne Bestellung freigeschaltet hatte, verlangt dieselbe Textform (bei einem gekauften oder schon aktiven Terminplaner entfällt sie). + +**Fundstelle im Text.** Abschnitt 3.5 (Vorbemerkung Abs. 2), Abschnitt 2.1 (Freischaltung ohne Bestellung); Programm: `termineTextformNoetig`, `textformFuerAktivierung`, `adminActivate`, `textformNachweisText`, `meldeFreigeschaltet`; Zusammenhang mit Q7. + +**Unsere Annahme.** Ja: Textform genügt für die Zustimmung zu Leistungsbeschreibung und AVV, der Nachweis des Mitarbeiters mit dem Chatverlauf im Portal ist hinreichend, und K16 liefert den dauerhaften Datenträger. + +**Alternative.** Die Zustimmung durch den Kunden selbst im Portal (Haken) einholen, auch für Kulanzfälle; K16 im Text auf die Anhänge hinweisen lassen. + +### Q22: BFSG-Satz ohne Konformitätszusage + +**Frage.** Leistungsbeschreibung Nr. 11 Abs. 2 sagt: 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 Kunde als Dienstleistungserbringer unter das BFSG fällt (Ausnahme für Kleinstunternehmen, § 3 Abs. 3 BFSG), entscheidet und prüft der Kunde. Ist die Formulierung ohne Konformitätszusage zulässig und für den Kunden klar genug? Nach unserem Stand gab es für das Widget bisher keinen Lauf mit einem automatischen Prüfwerkzeug oder einem Screenreader. + +**Fundstelle im Text.** Abschnitt 3.4 (Nr. 11 Abs. 2). + +**Unsere Annahme.** Ja. Der Satz sagt nur, was gestaltet wurde, und weist die Prüfung des BFSG dem Kunden zu. + +**Alternative.** Den Satz streichen; oder eine Prüfung des Widgets beauftragen und die Aussage danach anpassen. + +### Q23: Nachweis der Bestellung mit IP-Adresse und Browser; Aufbewahrung drei Jahre + +**Frage.** Beim Klick auf „Zahlungspflichtig bestellen“ speichert das Portal als Nachweis die gezeigten Texte und Fassungen, den Zeitpunkt, die Stripe-Sitzung, IP-Adresse und Browserkennung, das Konto und die angemeldete Person. Die Datenschutzerklärung sagt dazu: + +> 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. + +Ist die Speicherung der IP-Adresse und der Browserkennung für den Nachweis des Vertragsschlusses und der Zustimmungen erforderlich (Art. 6 Abs. 1 lit. f DSGVO) und die Aufbewahrung bis drei Jahre nach Ablauf des Jahres, in dem das letzte Abo endet, angemessen? Genügt die Information in der Datenschutzerklärung? Für Bestellungen ohne Abo (nur News) nennt die Erklärung eine eigene Frist: drei Jahre nach Ablauf des Jahres der Bestellung. Das Programm löscht diese Nachweise heute noch nicht; die Löschung wird vor Ablauf der ersten Frist (31.12.2029) nachgerüstet. Ist diese Frist angemessen? + +**Fundstelle im Text.** Abschnitt 2.1 Schritt 4, Datenschutzerklärung § 7 (Hausrunde); Programm: `speichereZustimmung`, `zustimmungFuerSitzung`, `findDueDeletions` (Löschung der Nachweise gemeinsam mit den Abo-Daten des Kunden; für Kunden ohne Abo noch offen, Abschnitt 7.7). + +**Unsere Annahme.** Ja: Der Nachweis dient dem Widerrufsrecht und der Beweisführung; die Frist entspricht der regelmäßigen Verjährung samt Ablauf des Jahres. + +**Alternative.** IP-Adresse und Browserkennung weglassen (nur Zeit, Texte, Konto); oder eine kürzere Frist, etwa bis zum Ablauf der Widerrufsfrist und der Gewährleistung. + +### Q24: Rollen in Abschnitt 6c; Entwurfsaufbewahrung 90 Tage + +**Frage.** Die Datenschutzerklärung von WEBklar (Abschnitt 6c) beschreibt WEBklar als Auftragsverarbeiter für die Daten der Gäste und als Verantwortlichen für die Betriebsdaten im Kundenportal, den Entwurf des Fragebogens und die Bestellung (C1). (a) Stimmt die Rollenverteilung? (b) Der Satz „mit der Buchung speichern wir die IP-Adresse nicht“ ist durch einen Test belegt (Buchung ohne IP und Browser-Kennzeichen); für den Missbrauchsschutz merkt sich der Server Zeitpunkte je IP-Adresse nur im Arbeitsspeicher (C2). Genügt die Darstellung? (c) Anfragen von Gästen leitet WEBklar an den Kunden weiter (A4). (d) Entwürfe aus dem Fragebogen ohne Bestellung löscht WEBklar 90 Tage nach der letzten Änderung: Ist diese Frist zweckgebunden und angemessen? + +**Fundstelle im Text.** Abschnitt 2.1 Schritt 2, Anlage A Nr. 8 und Nr. 9 Abs. 7; Datenschutzerklärung 6c (`docs/rechtliches/termine-datenschutz-6c.md`, Hausrunde); frühere Punkte C1, C2, A4; Programm: `ENTWURF_TAGE`. + +**Unsere Annahme.** Die Rollen sind richtig verteilt (wie beim Abschnitt 6b der Website-Analyse); 90 Tage Entwurf sind angemessen, weil Kunden Fragebögen über Wochen ausfüllen. + +**Alternative.** Entwürfe kürzer aufbewahren (30 Tage) oder nur auf ausdrückliches Speichern des Kunden. + +### Q25: Mindestlaufzeit des Wartungs-Abos und Terminplaner + +**Frage.** Das Wartungs-Abo hat eine Mindestlaufzeit von zwölf Monaten (§ 8 der Abo-Bedingungen) und ist danach zum Monatsende kündbar. Der Terminplaner hat keine eigene Laufzeit und kein Sonderkündigungsrecht; er endet mit dem Abo. Bestellbar ist er nur bei aktivem Abo ohne vorgemerkte Kündigung (Abschnitt 2.1 Schritt 1). (a) Ist es unbedenklich, dass der Kunde 149 € einmalig für etwas zahlt, das mit dem Abo enden kann, zum Beispiel wenn die Mindestlaufzeit bald endet? (b) Reicht Haken 4 („der Terminplaner endet mit dem Wartungs-Abo“) für die Transparenz, oder braucht die Bestellseite einen Hinweis auf das Ende der laufenden Mindestlaufzeit? + +**Fundstelle im Text.** Abschnitt 3.1 (Haken 4), Abschnitt 3.4 (Nr. 8 Abs. 1 und 5), Abo-Bedingungen § 8 (`public/abo-bedingungen.html`); Programm: `bestellbar`, `laeuft`. + +**Unsere Annahme.** Ja: Ohne vorgemerkte Kündigung läuft das Abo weiter; wird es später gekündigt, greift die Erstattung nur vor der Freischaltung, danach hat der Kunde den Terminplaner genutzt. + +**Alternative.** Auf der Bestellseite das Ende der Mindestlaufzeit anzeigen; bei Beendigung in den ersten Monaten nach der Freischaltung anteilig erstatten. + +### Q26: Nachweis der Gäste: Hash und Archiv der Textfassung + +**Frage.** Die Buchung eines Gastes speichert als Nachweis die Fassung der Gästetexte, den Zeitpunkt, den Text des Buchungsknopfs, die Anrede und Hashes der gezeigten Texte, nicht diese Texte selbst. WEBklar archiviert jede Fassung der Gästetexte (Hinweis am Formular, Texte der Haken, Datenschutz-Absatz, Belehrung für Gäste) mit Platzhaltern für die Betriebsangaben bis drei Jahre nach dem Ende des Terminplaners. Die Betriebsangaben (Name, Anschrift, Stornotext, Stunden der Erinnerung, Aufbewahrung) ändern sich später; der Stornotext hat eine eigene Version. Genügt der Hash zusammen mit dem Archiv der Textfassung, um die Zustimmung eines Gastes nachzuweisen? Der Nachweis beruht auf dem Textstand des Servers zum Zeitpunkt der Buchung, nicht auf dem, was das Widget angezeigt hat; wird die Fassung während der Haltezeit von 30 Minuten gewechselt, können beide abweichen (Änderungen sollen außerhalb der Stoßzeiten eingespielt werden). + +**Fundstelle im Text.** Abschnitt 2.2 Punkt 5, Abschnitt 3.5 (AVV Nr. 8 Abs. 5); Archiv `docs/rechtliches/archiv/termine-gaestetexte-2026-10-05.md`; Programm: `baueNachweis`, `baueArchiv`. + +**Unsere Annahme.** Ja: Mit der Fassung, dem Archiv und den Betriebsangaben des Zeitpunkts lässt sich der gezeigte Wortlaut rekonstruieren; das genügt für den Nachweis, ohne Volltexte je Buchung zu speichern. + +**Alternative.** Den vollständigen Text je Buchung speichern (mehr Daten, mehr Speicher); oder den Text je Betrieb und Fassung einmal als Schnappschuss ablegen. + +## 5. Fundstellen im Code + +Pfade gelten im Repository des Kundenportals (`Webklar-Kundenbereich`); Bezeichner sind Funktionen oder Konstanten und stehen in der genannten Datei. Zeilennummern nennen wir nicht, weil sie sich ändern. + +| Thema | Datei | Funktion/Konstante | +|---|---|---| +| Fassung der Vertragsunterlagen und Bestelltexte | `server/config.js` | `avvVersion` | +| Bestell-Haken und Knopf des Terminplaners (Register) | `server/config.js` | `termineConsent`, `TERMINE_PREIS` | +| Preisangabe und Umsatzsteuerhinweis | `server/config.js` | `ustHinweis` | +| Preis im Produktkatalog (14 900 Cent) | `server/config.js` | `termineSetup`, `amountCents` | +| Widerrufsbelehrung der Funktionen (WEBklar als Unternehmer) | `server/services/termine/rechtstexte.js` | `widerrufsbelehrungFunktion`, `WEBKLAR_KONTAKT` | +| Gästetexte: Belehrung, Haken, Hinweis, Datenschutz-Absatz | `server/services/termine/rechtstexte.js` | `widerrufsbelehrung`, `ZUSTIMMUNG_VORZEITIG`, `ZUSTIMMUNG_STORNO`, `ZUSTIMMUNG_ERINNERUNG`, `GESUNDHEIT_HINWEIS`, `formularHinweis`, `datenschutzAbsatz` | +| Fassung der Gästetexte | `server/services/termine/rechtstexte.js` | `RECHTSTEXTE_VERSION` | +| Knopftexte der Gäste, Widerrufsfunktion | `server/services/termine/einstellungen.js` | `knopfText`, `zeigtWiderruf` | +| Wann der Haken zum vorzeitigen Beginn gilt | `server/services/termine/einstellungen.js` | `brauchtZustimmungVorzeitig` | +| Eingabeprüfung der Buchung, Storno-Haken nur bei Preis, Nachweis mit Hashes | `server/services/termine/ablauf.js` | `pruefeEingabe`, `baueNachweis` | +| Storno-Vorschlag der Vorlagen (50 %, 24 Stunden) | `server/services/termine/vorlagen.js` | `stornoText`, `STORNO_PROZENT` | +| Zustimmungsnachweis der Bestellung | `server/services/featureConsents.js` | `speichereZustimmung`, `zustimmungFuerSitzung` | +| Bestellung: Reihenfolge Zahlungssitzung, Nachweis, Weiterleitung | `server/routes/funktionen.js` | `/:key/checkout`, `speichereNachweis`, `createFeatureCheckout` | +| Bestellbestätigung K15 mit PDF-Anhängen der vereinbarten Fassung | `server/services/siteFeatures.js` | `onFeaturePaid`, `k15Rechtsunterlagen` | +| Adressen und Dateien der Rechtsunterlagen | `server/services/legalDocs.js` | `legalPaths`, `legalPdfAttachments`, `featureLegalAttachments` | +| Abo-Prüfung (laufend, bestellbar) | `server/services/aboStatus.js` | `laeuft`, `bestellbar` | +| Täglicher Abo-Abgleich, Mail K22/K23, Erstattung T19 | `server/services/termine/aboAbgleich.js` | `gleicheAboAb`, `meldeAboEnde` | +| Abschalten, Wiedereinschalten, Abschaltgründe | `server/services/siteFeatures.js` | `disable`, `activate`, `DISABLED_REASONS` | +| Löschfrist nach dem Abschalten (30 Tage) | `server/services/termine/frist.js` | `LOESCHFRIST_TAGE` | +| Löschlauf, Fristen der Buchungen | `server/services/termine/jobs.js` | `loescheAbgeschaltete`, `VERFALLEN_BEHALTEN_MS`, `ABGESAGT_BEHALTEN_MS`, `AUFBEWAHRUNG_VORGABE` | +| Aufbewahrung der Entwürfe (90 Tage) | `server/services/termine/entwuerfe.js` | `ENTWURF_TAGE` | +| Versandwarteschlange für E-Mails | `server/services/termine/outbox.js` | `GESENDET_BEHALTEN_MS`, `FEHLER_BEHALTEN_MS` | +| Aufbewahrung der Zustimmungsnachweise der Bestellung | `server/services/retention.js` | `findDueDeletions`, `applyDeletions` | +| Gesundheits-Ausschluss: Branchen, Stichwortwarnung, Bestell-Gate | `server/services/termine/fragebogen.js` | `BRANCHEN`, `gesundheitsWarnungen`, `bestellpruefung` | +| Freischaltung ohne Bestellung: Textform nötig? | `server/services/termine/schalter.js` | `termineTextformNoetig` | +| Freischaltung ohne Bestellung: Prüfung von Datum und Weg, PDFs | `server/routes/admin/funktionenAdmin.js` | `textformFuerAktivierung`, `unterlagenAlsAnhang` | +| Freischaltung ohne Bestellung: Nachweis und Aktivierung | `server/services/siteFeatures.js` | `adminActivate`, `textformNachweisText` | +| Widerruf der Bestellung: Fristende je Bestellung, spätestes Fristende entscheidet über „rechtzeitig“ | `server/routes/abo.js` | `withdrawFeature` | +| Widerrufsfrist (14 Tage), Fristende | `server/services/deadlines.js` | `WITHDRAWAL_DAYS`, `withdrawalDeadline` | +| Widerruf durch Gäste (Widerrufsfunktion) | `server/routes/publicTermine.js` | `/:siteKey/widerruf`, `/termin/widerruf`, `widerrufeGast`, `widerrufeOhneLink` | +| Bestätigungs-E-Mail an Gäste, Hinweis kein Widerrufsrecht | `server/mail-templates/G02 Termin bestaetigt.html` | `kein_widerruf`, `widerrufsbelehrung` | +| Versand der Bestätigungs-E-Mail | `server/services/termine/mails.js` | `gastG02` | +| Eingangsbestätigung des Widerrufs | `server/mail-templates/G08 Eingang Widerruf.html` | `{{eingang}}`, `{{nummer}}`, `nach_frist` | +| Mail „Terminplaner beendet“ (30 Tage, Rückkehr) | `server/mail-templates/K22 Terminplaner beendet.html` | `loeschdatum`, `Rückkehr` | +| Mail „Terminplaner läuft wieder“ | `server/mail-templates/K23 Terminplaner laeuft wieder.html` | `{{kunde_name}}`, `innerhalb der Löschfrist` | +| Hinweise im Kundenportal (Widerruf, Freizeit, Storno-Muster) | `public/termine-tab-einstellungen.js` | `WIDERRUF_HINWEIS`, `FREIZEIT_HINWEIS`, `STORNO_MUSTER_HINWEIS` | +| Archiv der Gästetexte je Fassung | `scripts/termine-texte-archiv.mjs` | `baueArchiv` | +| HTML-Seiten der Vertragsunterlagen | `scripts/build-legal-html.mjs` | `termineDokumente` | +| PDFs der Vertragsunterlagen | `scripts/build-legal-pdfs.sh` | `termine_version`, `TERMINE_VERSION` | +| Muster der Widerrufsbelehrung (Anlage 1) | `docs/rechtliches/quellen/anlage-1.txt` | `Folgen des Widerrufs` | +| Muster-Widerrufsformular (Anlage 2) | `docs/rechtliches/quellen/anlage-2.txt` | `Muster-Widerrufsformular` | +| Datenschutzerklärung von WEBklar: Abschnitt 6c, § 6, § 7 | `public/datenschutz.html` | `id="termine"` | + +## 6. Nicht Teil der Kanzleiprüfung + +Dieser Abschnitt nennt, was wir der Kanzlei nicht zur Prüfung vorlegen, und warum. + +### 6.1 Hausrunde + +Diese Texte prüft WEBklar selbst (wie bei der Website-Analyse), weil sie keine der Kernfragen aus Abschnitt 1 betreffen: + +- die Datenschutzerklärung von WEBklar mit Abschnitt 6c „Online-Terminbuchung auf Websites unserer Kunden“ und den Ergänzungen in § 6 (Kundenansicht) und § 7 (Nachweise der Funktionsbestellung); Quelle `docs/rechtliches/termine-datenschutz-6c.md` und `public/datenschutz.html`. Nur die Rollenverteilung fragen wir (Q24), +- die Hinweise im Kundenportal (Widerrufs-, Freizeit- und Storno-Hinweise); zu zwei Punkten fragen wir (Q15, Q16), +- die Mailtexte (K15, K16, K22, K23, K04, T09, G02, G08); sie gehen durch die Mail-Überarbeitung des Inhabers. Die offene Formulierung in G02 steht in Q16 und in Abschnitt 7, +- die Einbauanleitung und das Einrichtungs-Ticket für Mitarbeiter. + +### 6.2 Später + +Diese Themen gehören nicht zu diesem Verkaufsweg und stehen für spätere Pakete bereit: + +- Abo-Bedingungen in der Fassung 6 bzw. ein Rahmen-AVV mit einer Anlage je Funktion, +- Themen der Online-Zahlung der Gäste (ein Satz zu Stripe in Abschnitt 6c, Zahlungsdienste, Vorauszahlung in den AGB, Preisangabenverordnung), +- Pflichtangaben auf Geschäftsbriefen (§ 37a HGB), +- Rechtsmissbrauch bei spätem Widerruf, +- Minderjährige, +- Mitgliedschaften, +- wiederholtes Versenden der Mails G08 und K21 nach Fristende, +- Widerruf nur vor Terminbeginn im Verwaltungslink. + +### 6.3 Frühere Listen „Für die Rechtsprüfung offen“ + +Beim Fertigstellen der Gästetexte und der Datenschutzerklärung haben wir Listen offener Rechtsfragen aus den Dokumenten entfernt. Jeder Punkt dieser Listen hat eine Kennung: H1 bis H4 (Hinweise-Dokument), A1 bis A6 (Datenschutz-Absatz für die Website der Betriebe), B1 bis B6 (Muster der Widerrufsbelehrung für Gäste), C1 bis C8 (Entwurf von Abschnitt 6c der Datenschutzerklärung). Jeder Punkt wird entweder bei einer Frage in Abschnitt 4 behandelt (die Kennung steht dort in der Fundstelle) oder ist hier mit Grund aufgeführt: + +| Kennung | Punkt | Wo behandelt | +|---|---|---| +| H1 | Belehrung vor dem Vertragsschluss: aufklappbare Darstellung im Widget | Q12 | +| H2 | Widerruf der Einwilligung am Erinnerungshaken | Q17 | +| H3 | Link zur Datenschutzerklärung neben dem Hinweis | Q17 | +| H4 | Bestätigungsfrist: 48 Stunden sind der früheste Zeitpunkt der Löschung | Q9 | +| A1 | Server-Protokolle: 14 Tage und Rechtsgrundlage | Q10 | +| A2 | Drittländer: gilt nur, solange Server und Mailversand bei Hetzner in Deutschland bleiben | Q10 | +| A3 | Löschlauf: Fristen sind die frühesten Zeitpunkte | Q9 | +| A4 | Kontakt der Gäste: Anfragen gehen an den Betrieb, WEBklar leitet weiter | Q24 | +| A5 | Gesundheitsbereich: Hinweis „Bitte keine Gesundheitsangaben“ | Q11 | +| A6 | Knopfmodus: Konfiguration schon beim Laden der Seite | Q10 | +| B1 | Lieferkosten-Klammer in der Belehrung | Q12 | +| B2 | „beginnen soll“ im Singular | Q12 | +| B3 | Anschrift und Telefon des Betriebs sind Pflicht | hier: technisch erledigt. `widerrufsbelehrung()` wirft einen Fehler, wenn eine der vier Angaben fehlt, und die Einstellungsprüfung verlangt beide Angaben. Keine Rechtsfrage. | +| B4 | Wertersatz-Absatz steht immer in der Belehrung | Q13 | +| B5 | Hinweis statt Internetadresse bei der Widerrufsfunktion | Q12 | +| B6 | Gesetzlichkeitsfiktion des Musters | Q12 | +| C1 | Rolle: Auftragsverarbeiter für die Gäste, Verantwortlicher für die Betriebsdaten | Q24 | +| C2 | IP-Adresse im Arbeitsspeicher: Satz „mit der Buchung speichern wir die IP-Adresse nicht“ | Q24 | +| C3 | Kalender-Abo-Link: Information der Beschäftigten | Q10 | +| C4 | Sicherungen: gelöschte Daten bis zu sieben Tage | Q9 | +| C5 | Zahlung der Gäste (spätere Online-Zahlung): Satz zu Stripe in Abschnitt 6c | hier: gehört zur späteren Online-Zahlung der Gäste (Abschnitt 6.2); der Terminplaner kennt heute nur die Zahlung vor Ort. | +| C6 | Geschäftsbriefe: Bestätigungen mit Preis sind keine Rechnungen | hier: Abschnitt 6.2 (Pflichtangaben auf Geschäftsbriefen); die Aufbewahrung nach Abschnitt 9 der Datenschutzerklärung betrifft den Terminplaner nicht. | +| C7 | Ende des Wartungs-Abos: Satz in Abschnitt 6c | Q9 | +| C8 | Knopfmodus: Formulierung zum Laden der Konfiguration in Abschnitt 6c | Q10 | + +## 7. Checkliste für den Inhaber + +Diese Punkte erledigt der Inhaber; kein Punkt ist Aufgabe der Kanzlei. Die Reihenfolge entspricht dem Ablauf bis zum ersten fremden Betrieb. + +### 7.1 Vor der Beauftragung der Kanzlei + +- [ ] Die PDFs der Anlagen A und B und der Widerrufsbelehrung einmal am Bildschirm ansehen (Kopfzeile „WEBklar · …“, „Seite x von y“, Tabelle der Anlage des AVV); wir konnten sie nur über ihre HTML-Seiten prüfen. +- [ ] Die Zahl der News-Bestellungen seit 2026-09-30 ermitteln, die mit dem alten Zustimmungstext und ohne Widerrufsbelehrung abgeschlossen wurden, und der Kanzlei nennen (Q4). +- [ ] Bestätigen, dass WEBklar Kleinunternehmer nach § 19 UStG bleibt (Q19). + +### 7.2 Hetzner (nur im Konto prüfbar) + +- [ ] Auftragsverarbeitungsvertrag mit Hetzner für die Cloud **und** für das Webhosting abgeschlossen (die Buchungs-E-Mails laufen über den SMTP-Dienst des Webhostings). +- [ ] Anlage 1 des Vertrags nennt „Terminbucher“, „Beschäftigte“ und „Buchungsdaten“ als Betroffene und Datenarten. +- [ ] Die Kontoadresse bei Hetzner stimmt mit dem Impressum überein. +- [ ] Die Datensicherung (Backups) ist eingeschaltet; AVV Nr. 9 Abs. 8 und die Abo-Bedingungen versprechen sieben Tagessicherungen. + +### 7.3 Uploads in WOMS „Rechtliches“ + +- [ ] Das Setup-Skript von WOMS mit API-Key ausführen: Es legt die drei Karten „Leistungsbeschreibung Terminplaner“, „AVV Terminplaner (Auftragsverarbeitung)“ und „Widerrufsbelehrung und Muster-Widerrufsformular (Funktionen)“ an. +- [ ] Für diese drei Karten die Version der Fassung hochladen (PDF oder HTML aus `public/rechtliches/`; Markdown ist im Bucket nicht erlaubt). +- [ ] Die neue Datenschutzerklärung (Stand 05.10.2026, `docs/WEBklar-Datenschutzerklaerung.pdf`) als neue Version der Karte „Datenschutzerklärung“ hochladen. +- [ ] „Gültig ab“ in `docs/rechtliches/archiv/README.md` eintragen (Fassung der Gästetexte vom 2026-10-05 steht auf „offen bis Livegang“; bei der Fassung vom 2026-10-02 prüfen, ob „gültig ab 2026-10-03 (Pilot)“ stimmt). + +### 7.4 Mailtexte + +- [ ] Die Platzhalter „ENTWURF (Mailrunde): …“ in `mails.danach` und `mails.bereit` des Terminplaners (Register in `server/config.js`) durch den endgültigen Wortlaut ersetzen. Sie stehen heute in K15 und K16 an Kunden. +- [ ] Die Kopfkommentare von K22 und K23 („Texte sind Entwurf für die Mail-Überarbeitung“) in der Mail-Überarbeitung abarbeiten. +- [ ] K23 (Terminplaner läuft wieder) sagt „Deine Termine und Einstellungen sind unverändert da“. Für die Termine stimmt das nicht immer, weil der Löschlauf auch bei abgeschaltetem Terminplaner abgesagte und erledigte Termine nach ihren Fristen löscht. Den Satz in der Mail-Überarbeitung abschwächen. +- [ ] Nach der Mail-Überarbeitung die gerenderten Mails K15 (mit den Zustimmungen) und K16 als **Anlage C** beilegen und der Kanzlei nachreichen; ihre Angemessenheit als Bestätigung auf einem dauerhaften Datenträger fragen Q3, Q20 und Q21. +- [ ] G02 zeigt die Stornobedingungen und den Satz „Absagen richten sich nach den Stornobedingungen“ (Block `kein_widerruf`) auch bei kostenlosen Buchungen. Einen neutralen Satz festlegen, der für Buchungen ohne Preis passt (Q16). + +### 7.5 Beauftragung und Freigabe + +- [ ] Die Kanzlei mit der Prüfung des Kernpakets und den Fragen Q1 bis Q26 beauftragen (Anlagen A und B beilegen; Umfang und Frist vereinbaren). +- [ ] Den Befund einarbeiten: Texte ändern, neues Fassungsdatum setzen, HTML und PDFs neu erzeugen, Archiv der Gästetexte und Prüfsummen nachziehen. Dieses Paket (`docs/rechtliches/termine-kanzlei-paket.md`) wird dabei nachgezogen; sein Test meldet jedes Zitat, das nicht mehr wörtlich im Programm oder in den Dokumenten steht. +- [ ] Über die News-Altfälle entscheiden (Q4): Belehrung per Mail nachholen oder nur künftig die neuen Texte. +- [ ] Die Fassung vor dem ersten fremden Betrieb ausdrücklich freigeben; erst danach die Adresse des ersten fremden Betriebs in die Pilotliste aufnehmen. +- [ ] Beim Livegang die Reihenfolge in Abschnitt 7.6 einhalten; den Datenschutz-Abschnitt der Test-Website des Terminplaners auf den Endtext bringen. + +### 7.6 Livegang: Reihenfolge + +Diese Schritte gelten für den Tag, an dem die Betriebsart `on` gesetzt wird. Die Reihenfolge ist wichtig, vor allem: beide Schemata vor dem Code-Deploy und `on` als letzter Schritt. + +- [ ] Vor dem Code-Deploy das Schema des Terminplaners anlegen, erst mit `--check`: `node scripts/termine-setup.mjs` ergänzt in `termineConfig` die Spalte `fragebogen`, in `termineBookings` die Indizes `idx_status_ends` und `idx_status_updated` und in `termineOutbox` den Index `idx_booking`. Ohne sie scheitern Schreibzugriffe und Abfragen des neuen Programms (der Fragebogen, der tägliche Löschlauf und das Löschen von Gastdaten). +- [ ] Vor dem Code-Deploy das Schema der Funktionen anlegen, erst mit `--check`: `node scripts/feature-setup.mjs` (neue Felder von `portalFeatures` und die Collection `featureConsents` mit ihren Indizes). Der Zeitpunkt ist der Code-Deploy, nicht erst der erste Löschlauf der Aufbewahrungsfristen: Schon die erste Bestellung des neuen Programms schreibt die neuen Felder, und ohne Schema antwortet Appwrite mit „Unknown attribute“. +- [ ] Hat sich `public/datenschutz.html` geändert, das PDF der Datenschutzerklärung mit `scripts/build-legal-pdfs.sh datenschutz` neu drucken, nicht mit `all` (das druckt auch die PDFs der Abo-Bedingungen und des Leitfadens neu); die Prüfsumme `docs/WEBklar-Datenschutzerklaerung.pdf.sha256` gehört dazu. +- [ ] Den Code zu einer ruhigen Zeit deployen. Der Nachweis einer Buchung speichert die Fassung der Gästetexte, die der Server beim Speichern hat; bucht ein Gast genau während des Deploys, kann sie von der abweichen, die er auf dem Bildschirm sah. +- [ ] Nach dem Deploy die Uploads in WOMS „Rechtliches“ erledigen (Abschnitt 7.3). +- [ ] Nach dem Deploy und nur mit Freigabe des Inhabers: `scripts/feature-consent-felder-leeren.mjs` zuerst als Probelauf ausführen (ohne Argument oder mit `--dry-run`; schreibt nichts), die Liste prüfen und erst dann mit `--apply` ausführen. Das Skript leert IP-Adresse und Browserkennung der Bestellung an den Funktionsdokumenten (`portalFeatures`), soweit der Nachweis (`featureConsents`) sie trägt; neue Bestellungen speichern sie dort nicht mehr. +- [ ] Als allerletzten Schritt `TERMINE_ENABLED=on` setzen, nachdem alle Punkte oben und die Freigabe der Fassung (Abschnitt 7.5) erledigt sind. + +### 7.7 Nach dem Livegang + +- [ ] Die Löschfrist für die Nachweise von Kunden ohne Abo (nur News) umsetzen, bevor die erste Frist abläuft (31.12.2029). Die Datenschutzerklärung (Abschnitt 7) sagt für Bestellungen ohne Abo „spätestens drei Jahre nach Ablauf des Jahres der Bestellung“; der Löschlauf (`findDueDeletions`) erfasst heute nur Kunden mit einem Abo, ein Kunde ohne Abo ist nie fällig. Umsetzung: Kunden ohne Abo-Dokument, aber mit Nachweisen in `featureConsents`, mit dem Ankerjahr der letzten Bestellung in den Plan aufnehmen und nur die Nachweise und die Bestellangaben am Funktionsdokument löschen, nicht die Beiträge. diff --git a/docs/rechtliches/webshop-kanzlei-fragen.md b/docs/rechtliches/webshop-kanzlei-fragen.md new file mode 100644 index 0000000..fbd2ee4 --- /dev/null +++ b/docs/rechtliches/webshop-kanzlei-fragen.md @@ -0,0 +1,3 @@ +# Fragen an die Kanzlei: Webshop + +Die Arbeitsliste der Fragen F1 bis F18 ist in das Kanzlei-Paket übernommen und dort um F19 bis F29 ergänzt worden. Maßgeblich ist jetzt: **`docs/rechtliches/webshop-kanzlei-paket.md`**, Abschnitt 4. Diese Datei bleibt nur als Verweis bestehen. diff --git a/docs/rechtliches/webshop-kanzlei-paket.md b/docs/rechtliches/webshop-kanzlei-paket.md new file mode 100644 index 0000000..590fd92 --- /dev/null +++ b/docs/rechtliches/webshop-kanzlei-paket.md @@ -0,0 +1,394 @@ +# Kanzlei-Paket Webshop (WEBklar) + +Dieses Paket gehört zum Verkaufsweg des Webshops der WEBklar GbR (Am Schwimmbad 10, 67722 Winnweiler, vertreten durch die Gesellschafter Kenso Grimm und Justin Klein). Es zeigt einer Kanzlei, was verkauft wird, wie der Ablauf ist, welche Texte Händler und Käufer in Endfassung sehen, wo diese Texte im Programm stehen und was wir wissen möchten. Es ist wie das Paket des Terminplaners (`docs/rechtliches/termine-kanzlei-paket.md`) aufgebaut; Fragen, die dort schon gestellt sind, nennen wir mit Verweis. + +Fassungen: Leistungsbeschreibung Webshop, Auftragsverarbeitungsvertrag Webshop (AVV) und die Bestelltexte tragen die Fassung **2026-10-05** (Konstante `config.shop.fassung`). Die Widerrufsbelehrung mit Muster-Widerrufsformular ist die gemeinsame der Funktionen (`docs/rechtliches/widerrufsbelehrung-funktionen.md`, Fassung des Terminplaners). Alle Texte sind noch nie ausgeliefert worden; der Webshop bleibt bis zu Ihrem Befund hinter dem Schalter `SHOP_ENABLED` (nur Pilot). + +## 1. Zweck und Umfang + +**Zweck.** Der Inhaber lässt die Texte des Webshops durch eine Kanzlei prüfen, bevor der erste fremde Betrieb ihn nutzt (Schalter `SHOP_ENABLED=on`). Das Paket soll ohne Rückfragen zeigen, was die Kanzlei prüfen soll und von welchen Annahmen wir ausgehen. + +**Was verkauft wird.** WEBklar verkauft nicht Waren, sondern ein Hilfsmittel: ein Shop-Widget für die Website eines Händlers samt Verwaltung im Kundenportal, Einrichtung einmalig 299 €, ohne Umsatzsteuer gemäß § 19 UStG, keine laufende Gebühr, keine Umsatzbeteiligung. Die Käufer bezahlen über Stripe Connect direkt an den Händler (eigenes Stripe-Konto, Direct Charges); WEBklar erhält und verwahrt kein Geld. Vertragspartner der Käufer ist der Händler. Der Webshop läuft, solange WEBklar die Website des Händlers betreibt; er ist nicht an Abo-Daten im Programm gekoppelt (Entscheidung W5, Abschnitt 2.5). + +**Kernpaket.** Wir bitten die Kanzlei, zu prüfen: + +- Leistungsbeschreibung Webshop (Anlage A) und AVV Webshop (Anlage B), vor allem Haftung, Ende mit dem Vertrag über den Betrieb der Website, ausgeschlossene Waren und Rolle bei Zahlungen (F1 bis F18), +- die Bestelltexte der Einrichtung: fünf Haken und Knopf (Abschnitt 3.1, F26), +- die Muster-Rechtstexte für Händler (Anlage C: AGB, Widerrufsbelehrung inklusive § 356a BGB, Muster-Widerrufsformular, Versand und Zahlung; F25), +- die Kasse im Widget: Knopf, Pflichtinformationen, Widerrufsfunktion und Gewährleistungshinweis (Abschnitte 3.3 und 3.4, F19, F20, F24), +- die Pflichtangaben am Artikel: Grundpreis, Lebensmittel, Produktsicherheit (F21 bis F23). + +**Hausrunde.** Die Datenschutzerklärung von WEBklar (Abschnitt 6d), den Muster-Absatz für die Datenschutzerklärung des Händlers (Anlage D), die Mailtexte und die Hinweise im Kundenportal prüft WEBklar selbst. Zu einzelnen Punkten daraus stellen wir trotzdem Fragen (F27, F28). + +**Begriffe.** *Händler* ist der Kunde von WEBklar, der den Webshop betreibt; *Käufer* sind die Besucher seiner Website, die bestellen. *Betriebsart* des Webshops ist `off` (abgeschaltet), `pilot` (nur Adressen einer Pilotliste) oder `on` (alle Kunden). Derzeit ist der Webshop nur für Konten des Inhabers freigeschaltet. `on` setzt der Inhaber erst nach Ihrem Befund und seiner Freigabe. + +**Keine Rechtsberatung durch WEBklar.** Die Texte für Händler sind Muster ohne Gewähr (Leistungsbeschreibung Nr. 6). Die rechtlichen Aussagen in diesem Paket (vor allem „unsere Annahme“ bei den Fragen) sind ein Arbeitsstand von WEBklar und kein Rechtsurteil. + +**Fassungen und Änderungen.** Ändern wir nach dem Befund einen Text, bekommt er ein neues Fassungsdatum und neue Dateien (HTML, PDF und Prüfsumme); die Zitate in diesem Paket werden dann angepasst. + +**Aufbau.** Abschnitt 2 beschreibt den Produktablauf, Abschnitt 3 enthält die Texte, Abschnitt 4 unsere Fragen F1 bis F29, Abschnitt 5 die Fundstellen im Programm, Abschnitt 6 was nicht Teil der Prüfung ist, Abschnitt 7 die Checkliste für den Inhaber vor `SHOP_ENABLED=on`. Anlagen: **Anlage A** Leistungsbeschreibung Webshop, **Anlage B** AVV Webshop, **Anlage C** Muster-Rechtstexte der Händler, **Anlage D** Muster-Datenschutzabsatz für die Händler-Website (Pfade in Abschnitt 3.5). + +## 2. Produktablauf in Kurzform + +### 2.1 Anfrage, Freigabe und Bestellung der Einrichtung + +| Schritt | Was geschieht | +|---|---| +| 1. Anfrage | Der Händler fragt den Webshop im Kundenportal für eine seiner Websites an: was er verkauft (Freitext und Warenart), ungefähre Artikelzahl, Versand und/oder Abholung, zwei Pflicht-Haken (er ist Unternehmer; er verkauft keine der ausgeschlossenen Waren), Nachricht an das Team. | +| 2. Prüfung | Das Team prüft die Anfrage (erlaubte Waren, Eignung) und nimmt sie an oder lehnt sie mit Begründung ab. Der Händler erhält eine Mail mit der Entscheidung. Ein Anspruch auf Freigabe besteht nicht (LB Nr. 2 Abs. 1). | +| 3. Bestellung | Nach der Freigabe bestellt der Händler die Einrichtung für 299 €: Zusammenfassung, volle Widerrufsbelehrung mit Muster-Widerrufsformular, fünf Haken, Knopf „Zahlungspflichtig bestellen · 299 €“ (Abschnitte 3.1 und 3.2). Eine Bindung an ein Abo besteht nicht. | +| 4. Nachweis | Der Server legt zuerst die Zahlungssitzung bei Stripe an und speichert den Nachweis: gezeigte Texte, Fassungen, Hash, Zeitpunkt des Klicks, IP-Adresse und Browser-Kennzeichen, Kundenkonto und angemeldete Person. Der Zeitpunkt des Klicks ist der Anker der Widerrufsfrist. | +| 5. Zahlung und Ticket | Stripe Checkout; danach legt das Portal ein Einrichtungs-Ticket mit der Einbauanleitung an, der Händler erhält die Bestellbestätigung mit den Unterlagen (Leistungsbeschreibung, AVV, Widerrufsbelehrung als PDF) in der Fassung, der er zugestimmt hat. | +| 6. Einrichtung | Der Händler verbindet sein Stripe-Konto (Onboarding bei Stripe), legt Artikel, Versand, Zahlungsarten und Rechtstexte an und bestätigt die Rechtstexte (Abschnitt 3.2). WEBklar baut das Widget und den Link „Vertrag widerrufen“ in die Website ein (Anleitung `docs/funktionen/shop-einbau.md`), fügt den Datenschutz-Absatz ein (Anlage D), macht mit dem Händler einen Testkauf mit Erstattung und schließt das Ticket; das schaltet die Funktion frei. Schaltet ein Mitarbeiter die Funktion stattdessen ohne Zahlung ein (Betriebsart `on`, Kunde nicht in der Pilotliste), verlangt das Portal vorher die Zustimmung des Kunden in Textform (Datum, Weg) und speichert sie als Zustimmungsnachweis (`featureConsents`, mit Mitarbeiter, ohne IP-Adresse und Browser-Kennzeichen); der Kunde erhält danach Leistungsbeschreibung und AVV als PDF (AVV Vorbemerkung Abs. 2). | +| 7. Öffnen | Der Händler öffnet den Shop (Schalter „Shop geöffnet“), sobald Einbau fertig, Stripe-Konto zahlungsfähig, Rechtstexte bestätigt, ein Artikel aktiv und Versand oder Abholung eingerichtet sind. Der Server prüft das bei jeder Bestellung erneut. | + +### 2.2 Käufer: Bestellung + +1. Der Käufer wählt Artikel (mit Grundpreis, Lebensmittel- und Produktsicherheitsangaben, Widerrufsausschluss), legt sie in den Warenkorb (im Sitzungsspeicher des Tabs, kein Cookie) und wählt Versand (nur Deutschland und Österreich) oder Abholung. Ganz oben im Widget stehen Lieferländer und Zahlungsarten. +2. In der Kasse stehen unmittelbar über dem Knopf die Pflichtinformationen, der Gewährleistungshinweis (Abschnitt 3.4) und der Knopf „Zahlungspflichtig bestellen“ (Abschnitt 3.3). Der Server rechnet alle Preise neu, prüft und reserviert den Bestand (30 Minuten). +3. Der Käufer wird zu Stripe Checkout geleitet und gibt dort Name, Adresse und Zahlung ein (Karte, Apple Pay, Google Pay, Klarna, SEPA-Lastschrift je nach Einstellung des Händlers; kein PayPal). Die Stripe-Kasse verlangt keine Telefonnummer (WEBklar erhebt und speichert auch keine) und fragt die Rechnungsanschrift nur ab, soweit die Zahlungsart sie braucht. SEPA gilt erst bei Eingang als bezahlt; bis dahin erhält der Käufer einmal eine Eingangsmail (G15) ohne Vertragsschluss. +4. Nach der Zahlung erhält der Käufer die Bestellbestätigung (G09, Abschnitt 3.3), der Händler die Mail „Neue Bestellung“ (K25). +5. Der Händler pflegt Status (versendet, abholbereit, abgeschlossen), erstattet über das Portal (Stripe-Rückzahlung auf dem Konto des Händlers) und exportiert Bestellungen als CSV. + +### 2.3 Käufer: Widerruf (§ 356a BGB) + +Der Käufer widerruft über „Vertrag widerrufen“ im Widget, auf der Standalone-Seite oder über den Link in der Fußzeile jeder Seite der Website: Schaltfläche, Formular (Name, E-Mail-Adresse, Bestellnummer „falls zur Hand“; ohne Bestellnummer ist die Erklärung Pflicht und soll Bestelldatum und Artikel nennen), Schaltfläche „Widerruf bestätigen“. Der Eingang wird gespeichert, der Käufer erhält sofort die Eingangsbestätigung mit Datum und Uhrzeit (G12), der Händler die Mail K26. Der Händler bearbeitet den Widerruf und die Erstattung selbst (LB Nr. 5 Abs. 6). Ohne Anmeldung, auch ohne passende Bestellung, mit Mengenbegrenzung gegen Missbrauch. ⚖ F20 + +### 2.4 Daten und Fristen + +Käuferdaten liegen auf dem Server bei Hetzner (Falkenstein), E-Mails laufen über den E-Mail-Dienst von Hetzner (Nürnberg). Nicht bezahlte oder stornierte Bestellungen werden nach 30 Tagen gelöscht; bei bezahlten Bestellungen und Widerrufen werden Name, E-Mail-Adresse und Anschriften drei Jahre nach dem Ende des Bestelljahres anonymisiert (Positionen, Beträge, Bestellnummer und Status bleiben); der IP-Hash (Hashwert der IP-Adresse für den Missbrauchsschutz, nur in der offenen Bestellung) wird gelöscht, sobald die Bestellung bezahlt oder storniert wird, spätestens mit der Löschung nicht bezahlter Bestellungen nach 30 Tagen; eine Telefonnummer des Käufers wird nicht gespeichert. Nach dem Abschalten sind Bestellungen 90 Tage lesbar und als CSV exportierbar, danach löscht der tägliche Job alle Daten des Webshops (Artikel, Bilder, Bestellungen, Widerrufe, Einstellungen, Archiv der bestätigten Rechtstexte); das Einrichtungs-Ticket bleibt ohne Käuferdaten. Gelöschte Daten bleiben bis zu sieben Tage in den Sicherungen. ⚖ F14 + +### 2.5 Ende mit dem Vertrag über den Betrieb der Website + +Der Webshop läuft, solange WEBklar die Website des Händlers betreibt (Hosting-Abo, Wartungs-Abo oder ein anderer Vertrag über den Betrieb der Website; LB Nr. 10 Abs. 1). Das Programm prüft das **nicht**: Es liest keine Abo-Daten für den Webshop und schaltet nicht von selbst ab. Das Team schaltet zum Vertragsende von Hand ab (Ticketsystem, Kunde, Funktionen, Webshop, „Abschalten“) und schreibt dem Händler vorher eine E-Mail. Beginnt innerhalb der 90 Tage wieder ein solcher Vertrag, schaltet das Team auf Wunsch wieder ein; endet der Vertrag, bevor der Webshop freigeschaltet ist, erstattet WEBklar die 299 € vollständig (von Hand in Stripe). ⚖ F9, F29 + +### 2.6 Widerruf der Bestellung durch den Händler + +Der Händler (auch ein Existenzgründer oder Verbraucher) widerruft auf der Seite „Vertrag widerrufen“ von WEBklar mit der Auswahl „Webshop (Einrichtung)“. Die Frist von 14 Tagen beginnt mit dem Klick auf „Zahlungspflichtig bestellen“. Das Team erhält die Mitteilung T09 mit Fristende, Preis und Projekt; es wird nichts automatisch geändert. Das Team prüft, erstattet in Stripe und schaltet den Webshop ab; mit dem Widerruf endet der Webshop (LB Nr. 9 Abs. 6). ⚖ F8 + +### 2.7 Zahlen + +| Wert | Bedeutung | Quelle im Code | +|---|---|---| +| 299 € | Einrichtung je Website-Projekt, einmalig, ohne Umsatzsteuer gemäß § 19 UStG | `SHOP_PREIS` in `server/config.js` | +| 14 Tage | Widerrufsfrist (Händler wie Käufer) | `WITHDRAWAL_DAYS` | +| 30 Minuten | Haltezeit einer offenen Bestellung (Checkout-Sitzung) | `bestellAblaufMinuten` in `server/config.js`, `legeBestellungAn` | +| 30 Tage | Löschung nicht bezahlter oder stornierter Bestellungen | `taeglicherJob` | +| 3 Jahre nach Ende des Bestelljahres | Anonymisierung der Käuferdaten | `anonymisierenJahre` in `server/config.js` | +| 90 Tage | Lesen und Export nach dem Abschalten, danach Löschung | `taeglicherJob` | +| 7 Tage | Eintrag in der Versandwarteschlange nach dem Versand; Sicherungen | AVV Nr. 9 | + +## 3. Texte in Endfassung + +Alle Zitate stehen wörtlich so im Programm oder in den Dokumenten (Abschnitt 5 nennt die Fundstellen). + +### 3.1 Bestellung der Einrichtung: fünf Haken und Knopf + +Auf der Bestellseite stehen vor dem Knopf die volle Widerrufsbelehrung mit Muster-Widerrufsformular und diese fünf Haken, keiner ist vorangekreuzt. Die Links auf Leistungsbeschreibung, AVV und Widerrufsbelehrung in der Fassung stehen daneben, nicht im Text der Haken. Gespeichert wird genau der gezeigte Text mit Fassung und Zeitpunkt. ⚖ F26 + +**Haken 1 (`unterlagen`)** + +> Ich stimme der Leistungsbeschreibung Webshop und dem Auftragsverarbeitungsvertrag Webshop in der Fassung 2026-10-05 zu. + +**Haken 2 (`verantwortung`)** + +> Ich verkaufe als Unternehmer in eigenem Namen. Für meine Produkte, Preise, Produktangaben und Rechtstexte (AGB, Widerrufsbelehrung, Impressum, Datenschutzerklärung) bin ich selbst verantwortlich; WEBklar stellt Muster bereit und berät nicht rechtlich. Ich verkaufe keine der in der Leistungsbeschreibung ausgeschlossenen Waren. + +**Haken 3 (`stripe`)** + +> Die Zahlungen meiner Käufer laufen über mein eigenes Stripe-Konto zu den Bedingungen von Stripe. WEBklar erhält und verwahrt kein Geld meiner Käufer und erhebt keine Umsatzbeteiligung. + +**Haken 4 (`beginn`)** + +> Ich verlange ausdrücklich, dass WEBklar vor Ablauf der Widerrufsfrist mit der Einrichtung des Webshops beginnt. Mir ist bekannt, dass mein Widerrufsrecht mit der vollständigen Erbringung der Einrichtung erlischt (§ 356 Abs. 5 Nr. 2 BGB). Widerrufe ich vor der vollständigen Erbringung, schulde ich einen angemessenen Betrag für die bis dahin erbrachte Leistung (§ 357a Abs. 2 BGB). + +**Haken 5 (`information`)** + +> Ich habe die Pflichtinformationen zu dieser Bestellung gelesen: Leistung (Einrichtung des Webshops für diese Website: Einbau des Shop-Widgets, Verwaltung im Portal, Muster-Rechtstexte), Gesamtpreis 299 € einmalig (ohne Umsatzsteuer gemäß § 19 UStG), voraussichtliche Dauer meist innerhalb von 2–3 Wochen, keine laufende Gebühr von WEBklar; der Webshop läuft, solange WEBklar die Website betreibt. Die Widerrufsbelehrung mit Muster-Widerrufsformular habe ich gelesen. + +**Knopf** + +> Zahlungspflichtig bestellen · 299 € + +### 3.2 Bestätigung der Rechtstexte durch den Händler + +Bevor der Händler den Shop öffnet, bestätigt er im Portal (Reiter Rechtliches) die Muster oder seine geänderten Texte mit diesem Haken. Zeit, Person und Hash der Texte werden gespeichert und die bestätigte Fassung wird zusätzlich im Archiv der Rechtstexte abgelegt (⚖ F16); jede Änderung der Texte hebt die Bestätigung auf. Neue Bestellungen werden mit der zuletzt bestätigten Fassung geschlossen. ⚖ F25 + +> Ich habe die Texte geprüft. Ich bin als Verkäufer für Rechtstexte, Produktangaben und Preise verantwortlich. WEBklar stellt Muster bereit und berät nicht rechtlich. + +### 3.3 Kasse im Widget und Bestellbestätigung + +Über dem Knopf zeigt das Widget (Quelle `public/embed/shop.js`, Zusammenfassung vom Server) unter den Überschriften „Ihre Bestellung“ (Positionen mit Einzel- und Summenpreis, Zwischensumme, Versandkosten oder Lieferart, Gesamtsumme, Steuerhinweis), „Lieferung und Zahlung“ (Lieferart, Lieferzeit, Zahlungsarten, Anbieter) und „Widerrufsrecht“, die Links „AGB lesen“, „Widerrufsbelehrung lesen“, „Versand & Zahlung lesen“, den Gewährleistungshinweis und den Satz „Name, Lieferadresse und Zahlung geben Sie im nächsten Schritt bei unserem Zahlungsdienstleister Stripe ein.“ Der Knopf heißt „Zahlungspflichtig bestellen“. Das Widerrufsrecht steht dort so: + +> Sie haben ein gesetzliches Widerrufsrecht, soweit unten für einzelne Artikel nichts anderes steht. Einzelheiten finden Sie in der Widerrufsbelehrung. + +Die Widerrufsfunktion führt mit diesen Texten: + +> Hier können Sie Ihren Vertrag widerrufen. Bitte geben Sie Ihren Namen, die E-Mail-Adresse Ihrer Bestellung und möglichst die Bestellnummer an. Sie steht in Ihrer Bestätigungs-E-Mail. + +> Bitte prüfen Sie Ihre Angaben. Mit „Widerruf bestätigen“ erklären Sie den Widerruf Ihres Vertrags. + +Die Bestellbestätigung G09 (`server/mail-templates/G09 Bestellbestaetigung.html`) und die Eingangsbestätigung G12 (`G12 Eingang Widerruf.html`) sind (zusammen mit der Eingangsmail G15 bei ausstehender Zahlung) Anlage E in der gerenderten Fassung nach der Mail-Überarbeitung (Abschnitt 7.4). ⚖ F19, F20, F27 + +### 3.4 Hinweis auf die gesetzliche Gewährleistung + +Der Hinweis steht hervorgehoben über dem Knopf (als Grafik `public/img/gewaehrleistung-eu.webp`, Wortlaut als Alternativtext) und in der Bestellbestätigung. Wortlaut (`gewaehrleistungsHinweis()`): + +> GESETZLICHE GEWÄHRLEISTUNG +> +> Mindestens zwei Jahre gesetzliche Gewährleistung der Vertragsmäßigkeit für Waren, die in der Europäischen Union verkauft werden. +> +> Verbraucherinnen und Verbraucher können ihre Rechte im Rahmen des gesetzlichen Gewährleistungsrechts geltend machen, z. B. wenn die Waren nicht der Beschreibung entsprechen, nicht bestimmungsgemäß funktionieren. +> +> Verkäufer haften für jede Vertragswidrigkeit, die zum Zeitpunkt der Lieferung der Waren bestand und innerhalb des Zeitraums der gesetzlichen Gewährleistung erkennbar wird. Verkäufer müssen in solchen Fällen Folgendes anbieten: kostenlose Nachbesserung oder kostenlose Ersatzlieferung, in bestimmten Fällen eine Preisminderung oder eine vollständige Erstattung des Kaufpreises. +> +> In einigen Ländern gilt ein längerer Zeitraum für die gesetzliche Gewährleistung. Für gebrauchte Waren kann ein kürzerer Zeitraum gelten, jedoch nicht weniger als ein Jahr. +> +> Für weitere Informationen zu Ihren Rechten in einem bestimmten Land scannen Sie den nachstehenden QR-Code oder fragen Sie den Verkäufer. europa.eu/youreurope/garantien +> +> Was ist zu tun, wenn Sie vertragswidrige Waren erhalten? 1. Melden Sie dem Verkäufer das Problem so bald wie möglich. 2. Legen Sie einen Kaufnachweis vor, z. B. die Quittung, Rechnung oder einen Kontoauszug. +> +> Verkäufer und Hersteller können auch gewerbliche Garantien gewähren, die unabhängig von der gesetzlichen Gewährleistung gelten. Diese GARAN-Kennzeichnung zeigt beispielsweise, dass der Hersteller eine gewerbliche Haltbarkeitsgarantie ohne zusätzliche Kosten gewährt, die die gesamte Ware abdeckt. + +⚖ F24 + +### 3.5 Dokumente und Fundstellen der Texte + +| Anlage | Dokument | Pfad | +|---|---|---| +| A | Leistungsbeschreibung Webshop (Markdown, HTML, PDF) | `docs/rechtliches/leistungsbeschreibung-webshop.md`, `public/rechtliches/leistungsbeschreibung-webshop-2026-10-05.html` und `.pdf` | +| B | Auftragsverarbeitungsvertrag Webshop (Markdown, HTML, PDF) | `docs/rechtliches/avv-webshop.md`, `public/rechtliches/avv-webshop-2026-10-05.html` und `.pdf` | +| C | Muster-Rechtstexte der Händler (Belehrung, Formular, AGB, Versand und Zahlung, Gewährleistungshinweis) | `docs/funktionen/shop-muster-rechtstexte.md` (Quelltext der Muster: `server/services/shop/rechtstexte.js`) | +| D | Muster-Datenschutzabsatz für die Website des Händlers | `docs/funktionen/shop-datenschutz-absatz.md` | +| E | Gerenderte Mails G09 bis G15 und K24 bis K26 (nach der Mail-Überarbeitung nachgereicht) | `server/mail-templates/` | +| – | Gemeinsame Widerrufsbelehrung der Funktionen mit Muster-Widerrufsformular | `docs/rechtliches/widerrufsbelehrung-funktionen.md` | +| – | Bestelltexte (fünf Haken, Knopf) | `server/config.js`, `shopConsent` | +| – | Datenschutzerklärung von WEBklar, Abschnitt 6d (Hausrunde) | `public/datenschutz.html`, `id="webshop"` | +| – | Einbauanleitung für das Team, inklusive Abschalten von Hand | `docs/funktionen/shop-einbau.md` | +| – | Hinweis auf die gesetzliche Gewährleistung (Grafik und Text) | `public/img/gewaehrleistung-eu.webp`, `server/services/shop/rechtstexte.js`, `gewaehrleistungsHinweis` | +| – | Widerruf der Bestellung durch den Händler | `public/widerrufen.html`, `server/routes/abo.js` | + +## 4. Fragen an die Kanzlei + +Die Markierung ⚖ steht nur hier und in den Abschnittsverweisen, nie in den veröffentlichten Texten. „Unsere Annahme“ ist ein Arbeitsstand von WEBklar und kein Rechtsurteil. Kürzel: **LB** = Leistungsbeschreibung Webshop, **AVV** = Auftragsverarbeitungsvertrag Webshop, **AB** = Abo-Bedingungen. Die Fragen F1 bis F18 betreffen Anlagen A und B, F19 bis F29 den Ablauf im Widget und die übrigen Texte. + +## Leistungsbeschreibung (Anlage A) + +### F1: LB Nr. 1 Abs. 4: Webshop im Hosting-Abo +**Text.** Leistungsbeschreibung A Nr. 2 Abs. 3 (keine Formulare mit Server) und Nr. 4 (Rechtstexte nicht enthalten) sowie Leistungsbeschreibung B Nr. 3 stehen dem Webshop „nicht entgegen“, auch im Hosting-Abo. +**Frage.** Reicht diese Klarstellung, um die statische Hosting-Website mit einem eingebundenen Widget zu vereinbaren, oder muss die Leistungsbeschreibung A selbst geändert werden (Änderungsverfahren § 15 AB)? Ist der Vorrang der LB vor den AB (Abs. 3) wirksam? +**Unsere Annahme.** Ja, weil das Widget auf dem Server von WEBklar läuft und die Website statisch bleibt. + +### F2: LB Nr. 3: Ausgeschlossene Waren und Abschaltung +**Text.** Katalog ausgeschlossener Waren (Alkohol, Tabak, Arzneimittel, Waffen und Feuerwerk, Erotik, lebende Tiere, digitale Inhalte, Gutscheine, Abonnements, Stripe-Liste); Abschalten nach Hinweis, bei Gefährdung auch ohne Hinweis; dann keine Erstattung des Einrichtungspreises, wenn der Händler den Verstoß zu vertreten hat. +**Frage.** Ist der Katalog gegenüber Unternehmern als AGB-Klausel haltbar (§ 307 BGB, auch bei Verweis auf eine fremde Liste wie die von Stripe, die sich ändert)? Ist die Abschaltung ohne vorherigen Hinweis und der Ausschluss der Erstattung angemessen? Sollte der Verweis auf die Stripe-Liste dynamisch bleiben? + +### F3: LB Nr. 4: Zahlungen, Rolle von WEBklar, ZAG +**Text.** Stripe Connect mit eigenem Konto des Händlers (Direct Charges), WEBklar erhält und verwahrt kein Geld, keine Umsatzbeteiligung, „WEBklar ist kein Zahlungsdienstleister“; PayPal nicht verfügbar. +**Frage.** Trägt diese Konstruktion ohne Erlaubnis nach dem ZAG (Bereichsausnahme für technische Dienstleister, § 2 Abs. 1 Nr. 9 ZAG)? Ist der Satz „WEBklar ist kein Zahlungsdienstleister“ als Aussage in der LB unbedenklich oder sollte er entfallen? Bleibt das so, wenn der Webshop später eine Plattformgebühr oder PayPal bekommt? +**Unsere Annahme.** Keine Erlaubnis nötig, solange kein Geld über WEBklar läuft und keine Gebühr einbehalten wird. + +### F4: LB Nr. 5: Pflichten des Händlers und Rolle als Hosting-Anbieter +**Text.** Der Händler verantwortet Artikel, Preise, Produktangaben (Lebensmittel, Produktsicherheit, Grundpreis), Rechtstexte, Lieferung, Gewährleistung, Rechnungen und Aufbewahrung. +**Frage.** Ist die Verteilung vollständig und vertretbar? Trifft WEBklar als Anbieter des Widgets eigene Pflichten (Verordnung über digitale Dienste für Hosting-Dienste; Produktsicherheitsverordnung für Online-Marktplätze), obwohl WEBklar nicht Verkäufer ist und keinen Marktplatz betreibt? Brauchen wir eine Meldestelle für rechtswidrige Angebote? + +### F5: LB Nr. 6: Muster-Rechtstexte ohne Gewähr +**Text.** WEBklar stellt Muster-AGB, Muster-Widerrufsbelehrung und Muster-Widerrufsformular, Versand und Zahlung bereit („Muster ohne Gewähr“, keine Rechtsberatung); das Widget zeigt den Hinweis auf die gesetzliche Gewährleistung (Verordnung (EU) 2025/1960). +**Frage.** Ist die Bereitstellung von Mustern als Nebenleistung eines technischen Produkts nach dem Rechtsdienstleistungsgesetz zulässig (§ 2 Abs. 3, § 5 RDG)? Genügt der Hinweis „Muster ohne Gewähr“ mit Prüfempfehlung? Kann WEBklar für Fehler der Muster haften (Abs. 2 verspricht richtige Beschreibung der Technik)? + +### F6: LB Nr. 8: Haftung +**Text.** Wie beim Terminplaner: Haftung für entgangenen Gewinn und Folgeschäden nur im Rahmen von § 13 Abs. 1 und 2 AB; keine Haftung für Inhalte, Preise und Rechtstexte des Händlers, Mustertexte, Ausfälle von Stripe, Rückbuchungen und Zahlungsausfälle. +**Frage.** Hält die Klausel der Inhaltskontrolle stand (Abs. 3 und 4 sind Ausschlüsse außerhalb des Einflussbereichs, keine Haftungsbegrenzung für eigene Pflichtverletzung)? Reicht der Verweis auf § 13 AB oder braucht der Webshop mit Zahlungsbezug eine eigene Haftungsgrenze (etwa Höchstbetrag)? + +### F7: LB Nr. 9 Abs. 5: Mehraufwand und Stundenkontingent +**Text.** Mehraufwand wird nach Freigabe mit 100 € je Stunde berechnet und nicht auf das Stundenkontingent angerechnet. +**Frage.** Das ist eine Entscheidung des Inhabers, keine Rechtsfrage; sie ist hier nur zur Kontrolle gelistet: Soll der Händler im Wartungs-Abo den Mehraufwand wahlweise über sein Kontingent abwickeln können (wie beim Terminplaner nach § 4 AB)? + +### F8: LB Nr. 9 Abs. 6: Widerruf der Einrichtung +**Text.** Verbraucher können die Bestellung der Einrichtung widerrufen; Verweis auf die gemeinsame Widerrufsbelehrung der Funktionen (Beispiel Terminplaner), Erlöschen nach der Zustimmung bei der Bestellung. +**Zitat der gemeinsamen Belehrung (`docs/rechtliches/widerrufsbelehrung-funktionen.md`).** + +> Das Widerrufsrecht gilt nur für Verbraucher. Wir zeigen die Belehrung und das Muster-Widerrufsformular trotzdem allen, die eine Funktion bestellen. Die Belehrung gilt für die Bestellung der Einrichtung einer Funktion im Kundenportal, zum Beispiel des Terminplaners. Hinweise dazu stehen nach dem Muster-Widerrufsformular. + +Die Belehrung nennt als Beispiel nur den Terminplaner, die Bestellung des Webshops verweist auf dieselbe Datei. + +**Frage.** Der Webshop richtet sich nur an Unternehmer (Nr. 1 Abs. 1), die Bestellung kann aber ein Existenzgründer oder ein Verbraucher vor Gewerbebeginn aufgeben. Ist es richtig, allen Bestellern die Belehrung zu zeigen? Muss die gemeinsame Belehrung den Webshop namentlich nennen, oder genügt das „zum Beispiel“ mit dem Terminplaner, weil der Webshop eine Funktion im Sinne des ersten Satzes ist? Eine Änderung der Datei würde auch die Fassung des Terminplaners betreffen (die Kanzlei-Prüfung der Belehrung selbst ist Teil des Pakets Terminplaner). + +### F9: LB Nr. 10: Ende mit dem Vertrag über den Betrieb der Website, Löschfrist, Wiedereinschaltung +**Text.** Der Webshop läuft, solange WEBklar die Website des Händlers betreibt (Hosting-Abo, Wartungs-Abo oder ein anderer Vertrag über den Betrieb der Website). Endet dieser Vertrag, endet auch der Webshop; WEBklar schaltet ihn zum Vertragsende ab und informiert den Händler vorher per E-Mail (Abs. 1, gilt auch beim Widerruf des Vertrags). 90 Tage Lesen und Export, danach Löschung; Wiedereinschaltung innerhalb der 90 Tage auf Wunsch ohne Kosten, wenn wieder ein Vertrag über den Betrieb der Website beginnt (Abs. 5); volle Erstattung des Einrichtungspreises, wenn der Vertrag vor der Freischaltung endet (Abs. 6). Technisch ist der Webshop nicht mit den Abo-Daten gekoppelt: Die Bestellung ist nicht an ein Abo gebunden, und das Abschalten zum Vertragsende erledigt das Team von Hand (WOMS, Funktionen, Webshop, „Abschalten“). +**Frage.** Ist die Kopplung des Webshops an den Vertrag über den Betrieb der Website wirksam, auch wenn der Händler einen laufenden Betrieb mit offenen Bestellungen und Widerrufen hat? Reicht der Hinweis an den Händler, seine Käufer zu informieren (Abs. 2), und die Vorab-Mail vor dem Abschalten? Sind die Erstattungs- und Wiedereinschaltungsregeln so vereinbar? Ist die Formulierung „anderer Vertrag über den Betrieb der Website“ bestimmt genug (§ 305c, § 307 BGB)? + +### F10: LB Nr. 11 Abs. 2: Beendigung bei Änderungen von Stripe +**Text.** WEBklar darf den Webshop (je Website mit vier Wochen, für alle mit drei Monaten Frist) beenden oder einstellen, auch wenn Stripe die Anbindung beendet oder wesentlich ändert. +**Frage.** Ist dieser zusätzliche Beendigungsgrund (gegenüber dem Terminplaner) wirksam, oder muss er enger gefasst werden? Was gilt für den bereits gezahlten Einrichtungspreis? + +### F11: LB Nr. 12 Abs. 2: Barrierefreiheit (BFSG) +**Text.** Wie beim Terminplaner: keine Zusage einer Konformität (WCAG 2.1 AA, EN 301 549); ob der Händler unter das BFSG fällt, entscheidet er selbst. +**Frage.** Ein Online-Shop ist ein Dienstleistungsangebot im elektronischen Geschäftsverkehr. Ist WEBklar mit dem Widget selbst Normadressat oder nur der Händler? Ist die Ausnahme für Kleinstunternehmen (§ 3 Abs. 3 BFSG) richtig dargestellt? Reicht „keine Zusage“, oder sollte WEBklar einen Mindeststandard für das Widget zusagen? + +## Auftragsverarbeitungsvertrag (Anlage B) + +### F12: AVV Vorbemerkung Abs. 4 und Nr. 2: Rollen +**Text.** Händler = Verantwortlicher, WEBklar = Auftragsverarbeiter, Stripe = Vertragspartner des Händlers (kein Unterauftragsverarbeiter). WEBklar verarbeitet Bestell- und Widerrufsdaten, versendet Bestell-E-Mails im Namen des Händlers und betreibt den Missbrauchsschutz. Dazu gehört ein gesalzener Hashwert der IP-Adresse in der Bestellung (Obergrenze von drei offenen Bestellungen je IP-Adresse in 30 Minuten; AVV Nr. 3 Abs. 4), gelöscht bei der Löschung oder Anonymisierung der Bestellung. +**Frage.** Ist WEBklar für alle diese Vorgänge reiner Auftragsverarbeiter, oder wird es beim Missbrauchsschutz (Arbeitsspeicher, Mengenbegrenzung, IP-Hash in der Bestellung) oder bei der Widerrufsfunktion (Eingangsbestätigung) zum eigenen Verantwortlichen (Art. 4 Nr. 7 oder Art. 26 DSGVO)? + +### F13: AVV Nr. 7 Abs. 4: Stripe und Datenweitergabe +**Text.** Stripe ist kein Unterauftragsverarbeiter; Zahlung, Kartendaten und Auszahlung verantworten Händler und Stripe. WEBklar ruft auf Weisung des Händlers die Zahlungs- und Adressangaben der Checkout-Sitzung ab und veranlasst Erstattungen. Daten von Käufern gehen nicht von WEBklar an Stripe. +**Frage.** Ist es richtig, den Datenfluss von Stripe zu WEBklar als Verarbeitung im Auftrag des Händlers zu beschreiben? Ist die Abgrenzung (Stripe als Auftragsverarbeiter und teils eigener Verantwortlicher laut DPA von Stripe) für die Datenschutzerklärung des Händlers richtig? Ist der Satz zu Drittlandübermittlungen von Stripe („regeln Kunde und Stripe“) ausreichend? + +### F14: AVV Nr. 9: Fristen für Anonymisierung und Löschung +**Text.** Nicht bezahlte Bestellungen (offen oder storniert): Löschung 30 Tage nach dem Anlegen. IP-Hash: gelöscht, sobald die Bestellung bezahlt oder storniert wird, spätestens mit dieser Löschung. Keine Telefonnummer des Käufers. Bezahlte Bestellungen und Widerrufe: Käuferdaten werden 3 Jahre nach dem Ende des Bestelljahres anonymisiert; Positionen, Beträge, Bestellnummer und Status bleiben. Nach dem Abschalten: 90 Tage Lesen und Export, dann Löschung aller Daten (auch des Archivs der Rechtstexte). Versandwarteschlange: Inhalt sofort nach dem Versand leeren, Eintrag nach 7 Tagen löschen, bei Fehlschlag nach 48 Stunden. Sicherung bis zu sieben Tage. +**Frage.** Sind 3 Jahre nach Ende des Bestelljahres für die Käuferdaten angemessen (Gewährleistung zwei Jahre, regelmäßige Verjährung drei Jahre ab Jahresende)? Entsteht ein Konflikt mit den Aufbewahrungspflichten des Händlers (zum Beispiel sechs oder acht Jahre nach AO und HGB), wenn WEBklar die Namen schon früher anonymisiert und der Händler den Export nicht gezogen hat? Reicht der Hinweis in Nr. 9 Abs. 8 und LB Nr. 5 Abs. 8, oder soll die Frist einstellbar sein? + +### F15: AVV Nr. 8 Abs. 2: Betroffenenrechte nur auf Weisung im Chat +**Text.** Auskunft und Löschung/Anonymisierung einer einzelnen Bestellung geschehen auf Weisung des Händlers in Textform (Chat), nicht über eine Aktion im Portal; der Händler prüft vorher seine Aufbewahrungspflichten. +**Frage.** Genügt das als Unterstützung nach Art. 28 Abs. 3 lit. e DSGVO? Welche Reaktionszeit sollte der AVV nennen („unverzüglich“)? + +### F16: AVV Nr. 8 Abs. 5: Archiv der bestätigten Rechtstexte und Prüfsumme +**Text.** Bestätigt der Händler seine Rechtstexte, legt WEBklar jede bestätigte Fassung einmal im Archiv ab (Wortlaut, Prüfsumme, Zeitpunkt, bestätigende Person des Händlers; keine Käuferdaten). Die Bestellung speichert die Prüfsumme der Fassung, die der Käufer sah; die Bestellbestätigung G09 zeigt die archivierte Fassung zu dieser Prüfsumme und nicht die beim Versand aktuelle (fehlt sie ausnahmsweise, gilt die aktuell bestätigte). WEBklar löscht das Archiv mit den übrigen Daten des Webshops 90 Tage nach dem Abschalten (AVV Nr. 9 Abs. 6), nicht früher; es ersetzt nicht die Aufbewahrung des Händlers (AVV Nr. 9 Abs. 8). Der Wortlaut bleibt also auch über die Anonymisierung der Bestellung (drei Jahre nach dem Bestelljahr) hinaus erhalten, solange der Webshop betrieben wird. +**Frage.** Genügt das als Nachweis des Händlers für den Vertragsinhalt (Bestellbestätigung mit den vollständigen Texten, § 312f Abs. 2 BGB)? Ist es richtig, die Fassungen bis 90 Tage nach dem Abschalten ohne weitere Frist aufzubewahren (Speicherbegrenzung; die Fassungen enthalten keine Käuferdaten, nur die bestätigende Person des Händlers), oder soll der AVV eine kürzere oder längere Frist nennen? + +### F17: AVV Nr. 3 Abs. 5: Warenkorb im Browser +**Text.** Kein Cookie; der Warenkorb (Artikelkennungen und Mengen, kein Personenbezug) liegt im Sitzungsspeicher (sessionStorage) des Tabs, „technisch erforderlich“. +**Frage.** Ist das nach § 25 Abs. 2 Nr. 2 TDDDG ohne Einwilligung zulässig? Welche Information muss der Händler dazu in seiner Datenschutzerklärung geben (der Musterabsatz dazu entsteht in Aufgabe J2)? + +### F18: AVV insgesamt: Übernahme der Textbausteine des Terminplaners +**Text.** Nummern 5, 6, 7 Abs. 1 bis 3, 10, 11 Abs. 1 und 12 sowie die Anlage sind inhaltlich aus dem AVV Terminplaner übernommen. +**Frage.** Gilt die Prüfung des AVV Terminplaner für diese Teile unverändert, oder ist wegen der Zahlungsdaten und der Adressdaten der Käufer (Liefer- und Rechnungsadresse, IP-Hash) eine Anpassung der Maßnahmen in der Anlage nötig? + +## Ablauf, Pflichtangaben, Datenschutz und Ende + +### F19: Vertragsschluss im Widget, § 312j BGB: Platz des Knopfes und Stripe nur als Zahlung +**Text.** Die verbindliche Bestellung geschieht im Widget des Händlers mit dem Knopf „Zahlungspflichtig bestellen“ (Abschnitt 3.3). Unmittelbar darüber stehen Positionen, Einzel- und Gesamtpreise, Versandkosten, Gesamtsumme, Lieferart und Lieferzeit, Zahlungsarten, Hinweis auf Widerrufsrecht oder -ausschluss je Artikel, Links auf AGB, Widerrufsbelehrung und Versand & Zahlung, der Gewährleistungshinweis (Abschnitt 3.4) und die Anbieterangaben. Name und Lieferadresse gibt der Käufer erst danach auf der Zahlungsseite von Stripe ein (ohne Telefonnummer, Rechnungsanschrift nur soweit nötig); Stripe Checkout ist nur die Zahlung. Bis zur Zahlung (30 Minuten) ist die Bestellung „offen“ und der Bestand reserviert; zahlt der Käufer nicht, verfällt sie. +**Frage.** (a) Erfüllt der Knopf mit der Beschriftung „Zahlungspflichtig bestellen“ im Widget § 312j Abs. 3 BGB, obwohl noch Daten (Name, Adresse) und die Zahlung folgen? (b) Erfüllen die Angaben über dem Knopf § 312j Abs. 2 BGB und Art. 246a § 1 Abs. 1 EGBGB, auch wenn die Lieferadresse noch fehlt? (c) Wann kommt der Vertrag zustande: mit dem Klick, mit der Zahlung oder mit der Bestätigung? Passt das zur Bestellbestätigung und zu den Mustertexten? (d) Ist die Informationspflicht zu Beginn des Bestellvorgangs (§ 312j Abs. 1 BGB: Lieferbeschränkungen und Zahlungsmittel) durch die Zeile ganz oben im Widget erfüllt? +**Unsere Annahme.** Ja: Der Händler gibt mit dem Klick im Widget das verbindliche Angebot ab; die Zahlung bei Stripe schließt den Vorgang ab; die Hinweise stehen unmittelbar über dem Knopf. +**Alternative.** Den Knopf erst auf der letzten Seite zeigen, nachdem Stripe die Daten erhoben hat (nicht möglich, weil Stripe Checkout die Seite betreibt), oder Name und Adresse im Widget abfragen und Stripe nur für die Zahlung nutzen. + +### F20: Widerrufsfunktion nach § 356a BGB: Ausgestaltung +**Text.** Die Schaltfläche „Vertrag widerrufen“ steht in der Fußzeile des Widgets, auf der Standalone-Seite unter `/shop//widerruf` und, vom Team eingebaut, als Link in der Fußzeile jeder Seite der Website des Händlers (Anleitung `docs/funktionen/shop-einbau.md`, Abschnitt 3). Sie führt ohne Anmeldung zu einem Formular (Name, E-Mail-Adresse, Bestellnummer „falls zur Hand“; ohne Bestellnummer muss die Erklärung Bestelldatum und Artikel nennen), danach zur Schaltfläche „Widerruf bestätigen“. WEBklar speichert den Eingang und sendet dem Erklärenden sofort eine Eingangsbestätigung mit Inhalt, Datum und Uhrzeit (G12) auf einem dauerhaften Datenträger; der Händler erhält die Mail K26. Die Funktion ist auch erreichbar, wenn keine Bestellung zur Angabe gefunden wird; der Händler klärt das. Die Bestellnummer ist ein freiwilliges Feld; fehlt sie, verlangt das Formular Bestelldatum und Artikel in der Erklärung (Freitext), damit der Händler zuordnen kann. +**Frage.** (a) Genügt diese Ausgestaltung § 356a BGB (Erreichbarkeit „während der gesamten Widerrufsfrist“, Schaltfläche, Bestätigungsschaltfläche, Eingangsbestätigung)? (b) Reichen Name, E-Mail-Adresse und entweder die Bestellnummer oder Bestelldatum und Artikel in der Erklärung zur Zuordnung („Identifikation“ des Vertrags), oder muss die Bestellung zwingend gefunden werden? (c) Ist der Link auf jeder Seite der Website des Händlers die richtige Verortung („Online-Benutzeroberfläche“), obwohl das Widget nur auf einer Seite läuft, und wer trägt die Pflicht, wenn der Händler den Link entfernt (LB Nr. 5 Abs. 6)? (d) Ist der Zeitstempel in Europe/Berlin mit Eingangsbestätigung ausreichend? +**Unsere Annahme.** Ja; die Funktion nimmt jede Erklärung an, die Zuordnung ist Sache des Händlers. +**Alternative.** Bestellnummer und E-Mail-Adresse als Pflichtfelder; Zuordnung mit Fehlermeldung bei Nichtfinden (würde Widerrufe abweisen und ist nach unserer Einschätzung riskanter). + +### F21: PAngV, Grundpreis immer je 1 kg, 1 l, 1 m, 1 m² +**Text.** Das Portal rechnet den Grundpreis immer auf 1 kg, 1 l, 1 m oder 1 m² um und zeigt ihn als „x € / 1 kg“; die Umrechnung auf 100 g oder 100 ml (die für Waren mit einem Nenngewicht bis 250 g erlaubt wäre) ist nicht vorgesehen. Der Händler gibt Menge und Einheit des Artikels an. Der Grundpreis steht neben dem Gesamtpreis im Artikel und in der Kasse. +**Frage.** (a) Ist es zulässig, auch bei kleinen Mengen immer den Grundpreis je 1 kg oder 1 l anzugeben? (b) Reicht die Pflege durch den Händler (Menge und Einheit je Artikel) oder braucht es Ausnahmen (Waren nach Stück, lose Ware, Mengen, bei denen ein Grundpreis entfällt)? (c) Wie ist der Grundpreis bei Varianten mit unterschiedlicher Menge zu zeigen? +**Unsere Annahme.** Zulässig und eindeutiger; die Ausnahmen kennt der Händler. + +### F22: Lebensmittel im Fernabsatz: lose Backwaren und Allergene +**Text.** Der Händler kann einen Artikel als Lebensmittel markieren; dann ist ein Pflichtfeld „Pflichtangaben“ auszufüllen (Bezeichnung, Zutaten, Allergene hervorgehoben, Menge, gegebenenfalls Hersteller), das vor dem Kauf am Artikel angezeigt wird. Ein typischer Kunde ist eine Bäckerei mit losen (nicht vorverpackten) Waren. Das Widerrufsrecht kann der Händler bei schnell verderblichen Waren ausschließen (§ 312g Abs. 2 Nr. 2 BGB). Die Lieferung und Abholung von Speisen ist in der Recherche als offen markiert. +**Frage.** (a) Genügt das Freitextfeld, um Art. 14 LMIV (Pflichtangaben im Fernabsatz vor Vertragsschluss) und die Allergenkennzeichnung nicht vorverpackter Ware zu erfüllen? (b) Muss der Shop die Angaben strukturieren (Allergenliste statt Freitext)? (c) Gilt für Lebensmittel, die der Händler selbst ausliefert oder zur Abholung bereitstellt, § 312 Abs. 2 Nr. 8 BGB oder der Ausschluss nach § 312g Abs. 2 Nr. 2 BGB, und was soll die Muster-Widerrufsbelehrung dazu sagen? +**Unsere Annahme.** Freitext genügt, wenn der Händler vollständig angibt; wir weisen ihn in der Leistungsbeschreibung (Nr. 5 Abs. 3) darauf hin. + +### F23: Produktsicherheit (GPSR): Pflichtfelder am Artikel +**Text.** Je Artikel erfasst der Händler den Hersteller (Name, Anschrift, E-Mail oder Website; Vorgabe „ich selbst“), bei Herstellern außerhalb der EU die verantwortliche Person in der EU sowie Warn- und Sicherheitshinweise. Das Widget zeigt diese Angaben am Artikel. Eine Produktkennung (Typ, Charge, Seriennummer) und ein Bild jedes Produkts als eigene Pflichtfelder gibt es nicht; Bilder sind optional (bis vier). +**Frage.** Erfüllen diese Felder Art. 19 der Verordnung (EU) 2023/988 (GPSR) für Angebote im Fernabsatz? Fehlen Pflichtangaben (Produktkennung, Bild)? Trifft WEBklar als Anbieter des Widgets selbst Pflichten (siehe auch F4)? +**Unsere Annahme.** Die Felder decken die Pflichtangaben für die üblichen Waren ab; die Verantwortung bleibt beim Händler. + +### F24: Hinweis auf die gesetzliche Gewährleistung (Verordnung (EU) 2025/1960 Anhang I) +**Text.** Seit dem 27.09.2026 ist der harmonisierte Hinweis hervorgehoben zu zeigen. Der Webshop zeigt die Grafik (`public/img/gewaehrleistung-eu.webp`) unmittelbar über dem Knopf „Zahlungspflichtig bestellen“, mit dem Wortlaut als Alternativtext, und führt denselben Text in der Bestellbestätigung. Der Wortlaut steht in Abschnitt 3.4. Der Abgleich der Grafikdatei mit der Originaldatei der Kommission steht noch aus. +**Frage.** (a) Entspricht Wortlaut und Gestaltung Anhang I (Größe, Hervorhebung, Position in der Kasse und im Angebot)? (b) Genügt die Einblendung in der Kasse, oder muss der Hinweis schon am Artikel stehen? (c) Genügt der Text in der Bestellbestätigung? (d) Wie ist der Hinweis bei Waren zu halten, die nicht dem Gewährleistungsrecht für Waren unterliegen? +**Unsere Annahme.** Kasse und Bestätigung genügen; die Grafik prüfen wir vor dem Livegang gegen die Datei der Kommission. + +### F25: Muster-Rechtstexte des Händlers (AGB, Widerrufsbelehrung, Widerrufsformular, Versand und Zahlung) +**Text.** Das Portal befüllt für den Händler Muster nach `docs/funktionen/shop-muster-rechtstexte.md` (Anlage C). Die Widerrufsbelehrung folgt Anlage 1 zu Art. 246a § 1 Abs. 2 S. 2 EGBGB (Fassung 2026 mit dem Baustein zur Widerrufsfunktion), enthält beide Fristbeginn-Alternativen (eine Ware, mehrere getrennt gelieferte Waren), Rücksendekosten zulasten des Käufers (umschaltbar), Abholung und Ausschlussgründe in allgemeiner Form (§ 312g Abs. 2 Nr. 1, 2, 3 BGB). Dazu kommen Muster-AGB für kleine Shops, das Muster-Widerrufsformular (Anlage 2) und Versand- und Zahlungsinformationen, die aus den Einstellungen erzeugt werden. Der Händler bestätigt die Texte mit dem Satz aus Abschnitt 3.2; die Prüfsumme der Texte steht an jeder Bestellung. +**Frage.** (a) Erfüllen die Muster, richtig ausgefüllt, die Gesetzlichkeitsfiktion (Art. 246a § 1 Abs. 2 S. 3 EGBGB)? (b) Stimmen die Muster-AGB mit der Technik überein (Vertragsschluss, Eigentumsvorbehalt, Gewährleistung, Streitbeilegung nach § 36 VSBG ohne Link auf die eingestellte OS-Plattform)? (c) Ist es zulässig, Muster als Nebenleistung zu liefern (siehe F5)? (d) Teilsendungen und regelmäßige Lieferung sind nicht vorgesehen: Muss die Belehrung das ausdrücklich sagen? +**Unsere Annahme.** Ja, wenn der Händler die Platzhalter richtig ausfüllt; Abweichungen im Einzelfall prüft der Händler (LB Nr. 6). +**Alternative.** Nur die Belehrung als Muster, AGB nur als Gliederung. + +### F26: Bestelltexte der Einrichtung: fünf Haken +**Text.** Die Bestellung der Einrichtung für 299 € trägt fünf Haken und den Knopf „Zahlungspflichtig bestellen · 299 €“ (Abschnitt 3.1). Alle Besteller sehen die volle Widerrufsbelehrung mit Muster-Widerrufsformular; Haken 4 verlangt den vorzeitigen Beginn mit Erlöschen und Wertersatz (§ 356 Abs. 5 Nr. 2, § 357a Abs. 2 BGB); Haken 5 bündelt die Pflichtinformationen. +**Frage.** Wie bei der Bestellung des Terminplaners (dort Q1 bis Q3, Q5, Q19, Q20): Genügen die fünf Haken, einschließlich Haken 3 („Die Zahlungen meiner Käufer laufen über mein eigenes Stripe-Konto …“) und Haken 5? Ist „der Webshop läuft, solange WEBklar die Website betreibt“ im Haken 5 als Information zur Laufzeit ausreichend, auch wenn das Ende des Webshops nicht an ein Abo, sondern an irgendeinen Vertrag über den Betrieb der Website gekoppelt ist (siehe F9)? +**Unsere Annahme.** Ja; wir gleichen die Antworten zum Terminplaner an. + +### F27: Bestellbestätigung an den Käufer (G09) und Absender +**Text.** G09 enthält Anbieter, Bestellnummer, Positionen, Preise, Versand, Gesamtsumme, Lieferart und Lieferzeit, Adresse, Zahlungsart, Hinweis auf Widerrufsausschlüsse, die vollständigen AGB, die vollständige Widerrufsbelehrung und das Muster-Widerrufsformular im Mailtext, den Link „Vertrag widerrufen“ und den Gewährleistungshinweis. Absender ist die Versandadresse von WEBklar mit dem Namen des Shops, Antworten gehen an den Händler. +**Frage.** (a) Erfüllt G09 § 312f Abs. 2 BGB (Bestätigung auf einem dauerhaften Datenträger mit den Vertragsinhalten und den Informationen nach Art. 246a EGBGB)? (b) Ist der Absender (Domain von WEBklar, Name des Shops, Reply-To Händler) transparent genug, oder muss der Händler als Absender erscheinen? (c) Müssen die Mails der Anlage zur Prüfung beigelegt werden? (Sie liegen als gerenderte Fassung nach der Mail-Überarbeitung vor.) +**Unsere Annahme.** Ja. + +### F28: Datenschutz: Abschnitt 6d, Stripe-Konto und Muster-Absatz +**Text.** (a) Datenschutzerklärung von WEBklar Abschnitt 6d („Webshop auf Websites unserer Kunden“): Rolle Auftragsverarbeiter für Käuferdaten, Verantwortliche für Betriebsdaten und Bestellung der Funktion. (b) Eröffnung des Stripe-Kontos des Händlers: WEBklar übermittelt auf Klick des Händlers dessen E-Mail-Adresse, die Adresse seiner Website und den Namen seines Projekts an Stripe Payments Europe Ltd. (Dublin), speichert Konto-ID und Stand und stützt das auf Art. 6 Abs. 1 lit. b DSGVO. (c) Muster-Absatz für die Datenschutzerklärung der Website des Händlers (`docs/funktionen/shop-datenschutz-absatz.md`, Anlage D): Stripe als eigener Verantwortlicher (Betrugsvorbeugung), Drittlandübermittlung (EU-US Data Privacy Framework), Klarna und SEPA über Stripe, Warenkorb im Sitzungsspeicher, E-Mails, Speicherdauer. +**Frage.** (a) Sind Rollen und Rechtsgrundlagen richtig (siehe auch F12, F13)? (b) Ist für die Übermittlung an Stripe bei der Kontoeröffnung eine Einwilligung oder Information des Händlers nötig, die über den Klick und Abschnitt 6d hinausgeht? (c) Reicht der Muster-Absatz für die Informationspflichten nach Art. 13 DSGVO (Verantwortlicher, Zwecke, Rechtsgrundlagen, Empfänger, Drittland, Dauer, Rechte), insbesondere für die Aussage zu Stripe und Klarna? (d) Passt die Speicherdauer (30 Tage unbezahlt, 3 Jahre bis zur Anonymisierung) zur Aufbewahrung des Händlers (siehe F14)? +**Unsere Annahme.** Ja; der Muster-Absatz folgt dem AVV Webshop. + +### F29: Ende des Webshops mit dem Vertrag über den Betrieb der Website, Abschalten von Hand +**Text.** Der Webshop ist nicht mit Abo-Daten im Programm gekoppelt, die Bestellung hängt nicht an einem Abo, und das Programm schaltet nicht von selbst ab (Entscheidung W5). Das Team schaltet zum Vertragsende von Hand ab und schreibt dem Händler vorher (Anleitung `docs/funktionen/shop-einbau.md`, Abschnitt 8). LB Nr. 10 Abs. 1 verspricht dieses Vorgehen; ein verspätetes Abschalten bleibt ein Organisationsrisiko von WEBklar. +**Frage.** Entstehen aus einem verspäteten Abschalten Ansprüche des Händlers oder der Käufer gegen WEBklar (etwa wenn der Shop nach dem Vertragsende weiter bestellbar bleibt)? Wie ist „zum Vertragsende“ in Abs. 1 zu fassen (Tag der Beendigung oder Tag danach)? Genügt die Haftungsregelung der LB Nr. 8 dafür? +**Unsere Annahme.** Das Risiko ist gering und beschränkt sich auf die Zeit zwischen Vertragsende und Abschalten; die Haftung ist nach LB Nr. 8 begrenzt. + +## 5. Fundstellen im Code + +Pfade gelten im Repository des Kundenportals (`Webklar-Kundenbereich`); Bezeichner sind Funktionen oder Konstanten und stehen in der genannten Datei. Zeilennummern nennen wir nicht, weil sie sich ändern. + +| Thema | Datei | Funktion/Konstante | +|---|---|---| +| Fassung, Preis, Betriebsart | `server/config.js` | `shop.fassung`, `SHOP_PREIS`, `SHOP_ENABLED` | +| Bestell-Haken und Knopf des Webshops (Register) | `server/config.js` | `shopConsent` | +| Betriebsart und Pilotliste | `server/services/shop/schalter.js` | `shopModus`, `shopFuerKunde` | +| Anfrage und Freigabe | `server/services/shop/anfrage.js` | – | +| Einstellungen, Zahlungsarten, Versand | `server/services/shop/einstellungen.js` | `STANDARD_EINSTELLUNGEN` | +| Artikel, Produktsicherheit, Lebensmittel, Grundpreis | `server/services/shop/produkte.js`, `server/services/shop/geld.js` | `normalisiereProdukt`, `grundpreisText` | +| Berechnung der Kasse (Preise nur aus der Datenbank) | `server/services/shop/preise.js` | `berechne` | +| Muster-Rechtstexte, Bestätigung, Gewährleistungshinweis | `server/services/shop/rechtstexte.js` | `musterWiderrufsbelehrung`, `musterWiderrufsformular`, `musterAgb`, `versandUndZahlung`, `BESTAETIGUNG_TEXT`, `gewaehrleistungsHinweis` | +| Stripe Connect: Konto, Onboarding, Checkout, Erstattung | `server/services/shop/connect.js` | `kontoAnlegen`, `onboardingLink`, `checkoutAnlegen`, `erstatten`, `connectEreignis` | +| Bestellung anlegen (IP-Hash, Stripe-Kasse ohne Telefonnummer), abschließen, Eingangsmail G15, verwerfen, Status, Erstattung, CSV | `server/services/shop/bestellungen.js` | `legeBestellungAn`, `ipHashVon`, `schliesseAb`, `meldeEingang`, `verwirf`, `setzeStatus`, `erstatte`, `csv` | +| Archiv der bestätigten Rechtstexte | `server/services/shop/archiv.js` | `archiviereRechtstexte`, `holeArchivierteRechtstexte` | +| Zustimmung in Textform bei Freischaltung durch das Team | `server/routes/admin/funktionenAdmin.js`, `server/services/shop/schalter.js`, `server/services/featureConsents.js` | `textformFuerAktivierung`, `shopTextformNoetig`, `speichereZustimmung` | +| Widerrufsfunktion der Käufer | `server/services/shop/widerrufe.js` | `normalisiereWiderruf` | +| Mails an Käufer und Händler | `server/services/shop/mails.js`, `server/mail-templates/` | `kaeuferWerte`, `haendlerK25`, `haendlerK26`; G09 bis G15, K24 bis K26, T21 | +| Öffentliche Schnittstelle des Widgets | `server/routes/publicShop.js` | Routen unter `/api/public/shop/v1/` | +| Widget | `public/embed/shop.js` | – | +| Löschfristen, Anonymisierung, Abschalten der Daten | `server/services/shop/jobs.js`, `server/services/shop/loeschen.js` | `taeglicherJob`, `anonymisierGrenze`, `loescheShopDaten` | +| Kundenlöschung | `server/services/shop/kundenLoeschung.js` | `schliesseShopsVonKunde` | +| Einrichtungs-Ticket mit Snippet und Schritten | `server/services/shop/ticketDetails.js` | `shopTicketDetails` | +| Rechtsunterlagen je Fassung | `server/services/legalDocs.js` | Satz `shop` | +| Widerruf der Bestellung | `server/routes/abo.js`, `public/widerrufen.html` | `ORDERS`, `FEATURE_OF_ORDER`, `orderLabel` | +| HTML-Seiten und PDFs der Vertragsunterlagen | `scripts/build-legal-html.mjs`, `scripts/build-legal-pdfs.sh` | Satz und Modus `shop` | +| Datenschutzerklärung von WEBklar | `public/datenschutz.html` | `id="webshop"` (Abschnitt 6d) | + +## 6. Nicht Teil der Kanzleiprüfung + +- **Hausrunde:** Datenschutzerklärung von WEBklar (Abschnitt 6d und die Ergänzungen in den Abschnitten 6, 7 und 12), die Hinweise im Kundenportal, die Mailtexte, die Einbauanleitung. Wir stellen nur die Fragen F27 und F28. +- **Gemeinsame Widerrufsbelehrung der Funktionen:** wird im Paket des Terminplaners geprüft; hier nur F8. +- **Die Rechtstexte der Händler im Einzelfall:** Jeder Händler bleibt für seine Texte verantwortlich (LB Nr. 5 Abs. 2 und Nr. 6). Wir bitten um Prüfung der Muster, nicht jedes Händlers. +- **Später:** Plattformgebühr, PayPal, Verkauf an Verbraucher außerhalb von Deutschland und Österreich, digitale Inhalte, Gutscheine, Abonnements, Rechnungserstellung durch WEBklar. Dafür gibt es keine Texte. +- **Steuerrecht der Händler** (Umsatzsteuer, Aufbewahrung): nur soweit F14 und F21 betroffen sind. + +## 7. Checkliste für den Inhaber vor `SHOP_ENABLED=on` + +Diese Punkte erledigt der Inhaber; kein Punkt ist Aufgabe der Kanzlei. Die Reihenfolge entspricht dem Ablauf bis zum ersten fremden Betrieb. + +### 7.1 Vor der Beauftragung der Kanzlei + +- [ ] Die PDFs der Anlagen A und B am Bildschirm ansehen (Kopfzeile, „Seite x von y“, Tabelle der Anlage des AVV); wir konnten sie nur über ihre HTML-Seiten prüfen. +- [ ] Bestätigen, dass WEBklar Kleinunternehmer nach § 19 UStG bleibt (Preis 299 € ohne Umsatzsteuer). +- [ ] Entscheiden, wie das Team den Vertragsende-Termin eines Händlers nicht verpasst (Erinnerung im Ticket, Kalender oder Abo-Liste), da der Webshop von Hand abgeschaltet wird (F29, `docs/funktionen/shop-einbau.md` Abschnitt 8). +- [ ] Entscheiden, ob Mehraufwand wahlweise über das Stundenkontingent abgerechnet werden darf (F7). +- [ ] Die Grafik `public/img/gewaehrleistung-eu.webp` mit der Originaldatei der Kommission abgleichen (F24). + +### 7.2 Hetzner und Stripe (nur im Konto prüfbar) + +- [ ] Auftragsverarbeitungsvertrag mit Hetzner für die Cloud **und** für das Webhosting (E-Mail-Versand) abgeschlossen; Anlage 1 nennt „Käufer“ und „Bestelldaten“ als Betroffene und Datenarten. +- [ ] Datensicherung eingeschaltet (AVV Nr. 9 Abs. 7 verspricht sieben Tagessicherungen). +- [ ] Stripe-Dashboard von WEBklar: Connect aktivieren (Plattformprofil: Verkäufer verkaufen eigene Waren, Direct Charges, keine Gebühr), Branding für das Onboarding setzen. +- [ ] Prüfen, dass der Vertrag mit Stripe die Nutzung von Connect für diesen Zweck erlaubt (F3). + +### 7.3 Technik + +- [ ] `node scripts/shop-setup.mjs --check`, dann `node scripts/shop-setup.mjs` (fünf Collections, darunter das Archiv der bestätigten Rechtstexte) und `node scripts/stripe-products.mjs --anlegen` (Produkt `shopSetup`), vor dem Code-Deploy. +- [ ] Connect-Webhook in Stripe anlegen (Ereignisse: `checkout.session.completed`, `checkout.session.async_payment_succeeded`, `checkout.session.async_payment_failed`, `checkout.session.expired`, `account.updated`, `charge.refunded`), Geheimnis als `STRIPE_CONNECT_WEBHOOK_SECRET` in `/home/webklar/apps/.env`, Zeilen `SHOP_ENABLED` und `SHOP_PILOT_EMAILS` in der Compose-Datei. +- [ ] Code nach `main` (nach dem Terminplaner), WOMS-Zweig nach `test`, Deploy zu einer ruhigen Zeit. +- [ ] PDF der Datenschutzerklärung mit `scripts/build-legal-pdfs.sh datenschutz` (nicht `all`), PDFs des Webshops mit `scripts/build-legal-pdfs.sh shop`; Prüfsummen mit den Tests abgleichen. + +### 7.4 Uploads in WOMS „Rechtliches“ und Mailtexte + +- [ ] In WOMS „Rechtliches“ Karten „Leistungsbeschreibung Webshop“ und „AVV Webshop“ bereitstellen (Setup-Skript von WOMS) und die Fassung 2026-10-05 hochladen; die neue Datenschutzerklärung (Stand 05.10.2026, `docs/WEBklar-Datenschutzerklaerung.pdf`) als neue Version hochladen. +- [ ] Mailtexte G09 bis G15, K24 bis K26, T21 in der Mail-Überarbeitung auf den endgültigen Wortlaut bringen, danach gerendert als Anlage E nachreichen (F27). + +### 7.5 Pilot, Beauftragung und Freigabe + +- [ ] Pilot mit dem Testkonto (`SHOP_ENABLED=pilot`): Anfrage, Freigabe, Bestellung, Einbau auf der Test-Website, Stripe-Konto, Testkauf für 1 € mit Erstattung, Widerruf über „Vertrag widerrufen“, Abschalten und Wiedereinschalten. +- [ ] Die Kanzlei mit der Prüfung des Kernpakets und den Fragen F1 bis F29 beauftragen (Anlagen A bis E beilegen; Umfang und Frist vereinbaren). +- [ ] Den Befund einarbeiten: Texte ändern, neues Fassungsdatum setzen (`config.shop.fassung`), HTML und PDFs neu erzeugen (`node scripts/build-legal-html.mjs shop`, `scripts/build-legal-pdfs.sh shop`), Prüfsummen und Tests nachziehen; dieses Paket nachziehen. +- [ ] Die Fassung ausdrücklich freigeben; erst danach `SHOP_ENABLED=on` setzen. +- [ ] Vor dem ersten fremden Betrieb: der Händler bestätigt seine Rechtstexte (Abschnitt 3.2), wir fügen den Datenschutz-Absatz (Anlage D) ein, Testkauf mit dem Händler. diff --git a/docs/superpowers/plans/2026-10-04-analyse-uebersicht.md b/docs/superpowers/plans/2026-10-04-analyse-uebersicht.md new file mode 100644 index 0000000..707f69b --- /dev/null +++ b/docs/superpowers/plans/2026-10-04-analyse-uebersicht.md @@ -0,0 +1,456 @@ +# Analyse-Übersicht neu („Cockpit“) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Die Analyse-Seite im Kundenportal zeigt eine Übersicht im „Cockpit“-Stil: Klartext-Zusammenfassung, Kennzahlen mit Vorzeitraum-Vergleich und Mini-Verlauf, Kontakt-Aktionen, Datenbasis; Heatmap und Einstellungen in eigenen Reitern. + +**Architecture:** Der Collector (`analytics/src/stats.js`) liefert zusätzlich Vorzeitraum, `firstDay`, Tageswerte und Durchschnitte; der Portal-Server reicht das durch. Im Browser: reine Funktionen (`analyse-view.js`), reine HTML-Bausteine (`analyse-cards.js`), DOM-Verdrahtung (`analyse.js`), Stil (`analyse.css`). Die Analyse-Oberfläche zieht aus `funktionen.js` aus. + +**Tech Stack:** Node 24 + `node:sqlite` (Collector), Node 20 + Express (Portal), Vanilla-JS-ES-Module im Browser, `node --test`, Playwright (`playwright-core` aus `/root/.npm/_npx/db89d7302a373f10/node_modules/playwright-core`). + +**Spec:** `docs/superpowers/specs/2026-10-04-analyse-uebersicht-design.md` + +## Global Constraints + +- Arbeitsort: Worktree `/home/webklar/apps/kundenbereich.webklar.com/dev-analyse-neu`, Branch `feature/analyse-uebersicht`. `node_modules` ist ein Symlink auf `../build/node_modules` und bleibt untracked: **nie `git add -A`**, immer Dateien einzeln adden. +- Keine neuen Abhängigkeiten. Keine Änderung an `analytics/client/*`, `public/rechtliches/*`, Banner, AVV, Leistungsbeschreibung, Datenschutztexten, WOMS, Schema, `.env`. +- Nur vorhandene Daten (Spec U4). Klartext nur durch feste Regeln im Browser (U5). +- Texte der Zustände Einschalten und Ausschalten bleiben **wörtlich** wie heute (bestehende Tests in `test/funktionenTiles.test.js` prüfen sie). +- Neue Texte: Deutsch, Du-Form klein („du“, „deine“), Zahlen `de-DE` („6.930“, „7,8“). +- Element-IDs bleiben: `#analyse-body`, `#an-stage`, `#an-preview`, `#an-avv`, `#an-on`, `#an-error`, `#an-color`, `#an-hint`, `#an-off`, `#an-off-*`, `#an-heat-page`. +- Alle Werte aus Daten (Seitenpfade, Hosts, Projektnamen) werden in HTML maskiert (`esc`). +- Akzent nur unter `.an`: `--an-acc: #2dd4bf`, `--an-acc-strong: #5eead4`, `--an-acc-soft: rgba(45,212,191,.1)`, weniger `#fda4af`, Karte `rgba(14,16,20,.92)`, Rand `rgba(255,255,255,.09)`, Radius 16 px, Abstand 14 px. +- Reiter-Schlüssel: `'uebersicht' | 'heatmap' | 'einstellungen'`. Zeiträume: 7, 30, 90. +- Portal-Tests laufen auf Node 20 (Produktion) und 24; Collector-Tests auf Node 24. +- Commit-Nachrichten enden mit `Co-Authored-By: Claude Opus 5.5 `. + +## Review Focus + +1. Antwort eines **alten Collectors** ohne `previous`, `firstDay`, `from/to` und ohne `series[].visitors` (Deploy-Reihenfolge): Seite rendert ohne Chips und ohne Fehler, Mini-Verläufe nur, wo Werte da sind → Test in Task 4. +2. **Website ohne einen einzigen Aufruf** (alles 0, `consentRate` 0, `avgSec` null): keine `NaN`/`Infinity`/`undefined` im HTML, Leerzustand im Block „Das Wichtigste“, Ring 0 % → Test in Task 4. +3. **Seitenpfad oder Host mit HTML** (`/`, Host `a"b.de`): überall maskiert (Hinweise, Ranglisten, Heatmap-Auswahl) → Tests in Task 3 und Task 4. +4. **Schnelles Umschalten** von Zeitraum/Website: eine langsame ältere Antwort überschreibt die neuere nicht → Playwright-Check mit verzögerter Antwort in Task 5. +5. **Sehr langer Pfad** auf dem Handy (402 px): keine waagrechte Scrollleiste, Text gekürzt → Playwright-Check in Task 5. + +--- + +### Task 1: Collector liefert Vorzeitraum, firstDay, Tageswerte, Durchschnitte; Portal liefert activatedAt + +**Files:** +- Modify: `analytics/src/stats.js` (gesamte Datei, 69 Zeilen) +- Modify: `analytics/test/stats.test.js` (Test „Stats-Endpunkt: Form …“ ab Zeile 118 anpassen, neue Tests anhängen) +- Modify: `test/analyticsContract.test.js:42-50` +- Modify: `server/routes/funktionen.js:51-75` (`analyseView`) +- Modify: `test/analyseRoutes.test.js:115` (+ neuer Test) + +**Interfaces:** +- Produces: Antwort von `computeStats(db, site, range, now)` (und damit `GET /a/admin/stats/:key` und `GET /api/funktionen/analyse/stats`) mit den Feldern aus Spec Abschnitt 1: + `range`, `from`, `to`, `firstDay`, `totals { pv, visitors, contacts, consentRate, engaged, avgSec, avgScroll }`, `series [{ day, pv, visitors, contacts, avgSec }]`, `pages`, `refs`, `countries`, `devices`, `contacts`, `banner`, `previous { from, to, complete, totals {…gleich…}, series […gleich…], contacts [{key,count}] }`. +- Produces: `p.analyse.activatedAt` (ISO-String oder `null`) in `GET /api/funktionen`. + +- [ ] **Step 1: Failing Tests im Collector schreiben** (`analytics/test/stats.test.js`, Helfer `mk`, `admin`, `KEY`, `NOW` = 10.10.2026 12:00 Berlin, `D`, `H` existieren) + +Bestehenden Test „Stats-Endpunkt: Form …“ anpassen: +```js +assert.deepEqual(Object.keys(j).sort(), ['banner', 'contacts', 'countries', 'devices', 'firstDay', 'from', 'pages', 'previous', 'range', 'refs', 'series', 'to', 'totals']); +assert.deepEqual(j.totals, { pv: 4, visitors: 2, contacts: 1, consentRate: 2 / 4, engaged: 1, avgSec: 5, avgScroll: 5 }); +assert.deepEqual(j.series[5], { day: '2026-10-09', pv: 3, visitors: 1, contacts: 0, avgSec: 5 }); +assert.deepEqual(j.series[6], { day: '2026-10-10', pv: 1, visitors: 1, contacts: 1, avgSec: null }); +assert.equal(j.from, '2026-10-04'); assert.equal(j.to, '2026-10-10'); assert.equal(j.range, 7); +``` +Neue Tests: +```js +test('Stats: Vorzeitraum = die range Kalendertage davor, complete nach firstDay', async () => { + // pv am 30.09. (im Vorzeitraum 27.09.–03.10.), en am 01.10., ct am 02.10., pv am 26.09. (vor dem Vorzeitraum) + // erst ohne 26.09.: firstDay '2026-09-30', complete false; dann mit 26.09.: firstDay '2026-09-26', complete true + assert.equal(j.previous.from, '2026-09-27'); assert.equal(j.previous.to, '2026-10-03'); + assert.equal(j.previous.series.length, 7); assert.equal(j.previous.series[3].day, '2026-09-30'); + assert.equal(j.previous.totals.pv, 1); // 26.09. zählt nicht mit + assert.deepEqual(j.previous.contacts, [{ key: 'tel', count: 1 }]); + assert.equal(j.totals.pv, 0); // aktueller Zeitraum leer +}); +test('Stats: firstDay aus daily und Rohdaten, leer ohne Daten', async () => { /* '' ohne Ereignisse; nach runNightly weiterhin gleich */ }); +test('Stats: avgSec/avgScroll gerundet, null ohne en; Tageswert avgSec', async () => { + // zwei en am selben Tag: sec 30/scroll 50 und sec 91/scroll 100 → avgSec 61 (60,5 gerundet), avgScroll 75, engaged 2 +}); +test('Stats: Vorzeitraum über die Zeitumstellung lückenlos und ohne Überlappung', async () => { + const NOV = Date.UTC(2026, 10, 10, 10); // range 30 + // series[0].day '2026-10-12', series[29].day '2026-11-10', previous.series[29].day '2026-10-11', previous.series[0].day '2026-09-12' + // alle 60 Tage verschieden +}); +test('Stats: Vorzeitraum aus daily und live, nie doppelt', async () => { /* Ereignis im Vorzeitraum vor und nach runNightly: previous.totals.pv bleibt 1 */ }); +``` +Die Ereignisse der Tests mit `ev({ ts, type, … })` einfügen; `runNightly(db, { now: NOW })` verdichtet Tage < heute. + +- [ ] **Step 2: Tests laufen lassen, sie schlagen fehl** + +Run: `cd analytics && node --test test/stats.test.js` +Expected: FAIL (fehlende Felder `previous`, `firstDay` …) + +- [ ] **Step 3: `computeStats(db, site, range, now = Date.now())` in `analytics/src/stats.js` umbauen** + +Aufteilen in eine Sammel-Funktion für eine beliebige Tagesliste und eine Formungs-Funktion: +- `dayList(endDay, range)` bildet Kalendertage per `Date.UTC(y, m-1, d-i)` (wie heute). Aktueller Zeitraum: `dayList(dayOf(now), range)`. Vorzeitraum: `dayList(, range)` – kalendarisch, nie `now - range*DAY_MS`. +- `collect(db, site, days, now)`: `daily` für `day IN days` und noch nicht in `agg_state` stehende Tage live aus `events_raw` (Abfrage begrenzt auf Mitternacht UTC des ersten Tags − 2 Tage bis letzter Tag + 2 Tage und `ts <= now`, dann nach `dayOf(ts)` gefiltert; `aggregateRows` aus `jobs.js` wie heute). Ergebnis je Tag: `pv_total`, `visitors`, `ct_kind`-Summe, `en_count`, `en_sec_sum`, `scroll_sum` sowie die Top-Dimensionen über alle Tage. +- Durchschnitte: `avgSec = Math.round(en_sec_sum / en_count)`, `avgScroll = Math.round(scroll_sum / en_count)`, beide `null` bei `en_count = 0`; `engaged = en_count`. +- `firstDay`: Minimum aus `SELECT MIN(day) FROM daily WHERE site = ?` und `dayOf(SELECT MIN(ts) FROM events_raw WHERE site = ?)`, `''` wenn beides fehlt. `previous.complete = firstDay !== '' && firstDay <= previous.from`. +- `pages`, `refs`, `countries`, `devices`, `contacts`, `banner`, `consentRate` bleiben exakt wie bisher (nur für den aktuellen Zeitraum); `previous.contacts` = `top(ct_kind)` des Vorzeitraums. + +- [ ] **Step 4: Collector-Tests grün** + +Run: `cd analytics && npm test` +Expected: PASS (bisher 137 Tests + neue), 0 fail + +- [ ] **Step 5: Vertragstest ergänzen** (`test/analyticsContract.test.js` nach Zeile 50) + +```js +assert.equal(stats.range, 7) +assert.equal(stats.previous.series.length, 7) +assert.ok('firstDay' in stats && 'avgSec' in stats.totals) +assert.ok(stats.series.every((d) => 'visitors' in d && 'contacts' in d && 'avgSec' in d)) +``` +Run: `node --test test/analyticsContract.test.js` → PASS + +- [ ] **Step 6: Failing Test für activatedAt** (`test/analyseRoutes.test.js`) + +Zeile 115: erwartetes Objekt um `activatedAt: null` ergänzen. Im Test nach erfolgreichem `activate` (Zeile ~128–135): `assert.match(view.activatedAt, /^\d{4}-\d{2}-\d{2}T/)`. +Run: `node --test test/analyseRoutes.test.js` → FAIL + +- [ ] **Step 7: `analyseView` in `server/routes/funktionen.js` gibt `activatedAt: doc?.state === 'active' ? doc.activatedAt || null : null` zurück** + +Run: `node --test test/analyseRoutes.test.js` → PASS + +- [ ] **Step 8: Gesamte Portal-Suite** + +Run: `npm test` +Expected: PASS, 0 fail (Ausgangslage 2259 pass) + +- [ ] **Step 9: Commit** + +```bash +git add analytics/src/stats.js analytics/test/stats.test.js test/analyticsContract.test.js server/routes/funktionen.js test/analyseRoutes.test.js +git commit -m "Analyse: Vorzeitraum, firstDay, Tageswerte und Durchschnitte in der Statistik; activatedAt in der Ansicht" +``` + +--- + +### Task 2: Analyse-Oberfläche zieht aus funktionen.js aus (ohne sichtbare Änderung) + +**Files:** +- Create: `public/fn-util.js` +- Create: `public/analyse.js` +- Modify: `public/funktionen.js` (Zeilen 6–7 Importe, 28–61 Helfer, 101–110 Analyse-Zustand, 611–980 Analyse-Abschnitt) +- Modify: `public/analyse-view.js` (Zustand und Auswahl-Helfer dazu) +- Modify: `public/app.js:1920` +- Modify: `test/funktionenTiles.test.js:8-16`, `test/analyseView.test.js:50-59` + +**Interfaces:** +- Produces: `public/fn-util.js` exportiert `esc(value): string`, `safeUrl(url): string`, `request(path, { method, body, raw }): Promise` (unverändert aus `funktionen.js` verschoben). +- Produces: `public/analyse-view.js` exportiert zusätzlich `analyseOf(project)`, `analyseState(a)`, `pickAnalyseProject(projects)` (verschoben) und `export const analyseSel = { projectId: '', range: 30, tab: 'uebersicht' }` (gemeinsamer Zustand für Kachel und Analyse; ersetzt das bisherige `analyseSel` in `funktionen.js`). +- Produces: `public/analyse.js` exportiert `initAnalyse(root, { projectId })`, `renderAnalyseActivation(stage, project, reload)`, `renderAnalyseOff(box, projectId, reload)` (Verhalten unverändert). +- Importstil: `funktionen.js` importiert absolut (`'/fn-util.js'`, `'/analyse-view.js'`), `analyse.js` und `analyse-view.js` relativ (`'./fn-util.js'`, `'./analyse-view.js'`, `'./geraete.js'`) – so lassen sie sich in Node direkt importieren und sind im Browser dieselben Module. + +- [ ] **Step 1: Tests auf die neue Aufteilung umstellen (schlagen fehl)** + +`test/funktionenTiles.test.js`: zusätzlich `.replace("'/fn-util.js'", `'${fileUrl('fn-util.js')}'`)`; `initAnalyse`, `renderAnalyseOff` aus `await import(fileUrl('analyse.js'))` statt aus `funktionen.mjs`. +`test/analyseView.test.js` („verdrahtet“): +```js +assert.match(app, /analyse: \(\) => import\('\/analyse\.js'\)/) +const an = readFileSync(new URL('../public/analyse.js', import.meta.url), 'utf8') +assert.doesNotMatch(an, /\bconfirm\(/) +const fn = readFileSync(new URL('../public/funktionen.js', import.meta.url), 'utf8') +assert.doesNotMatch(fn, /function renderAnalyseStats|function renderAnalyseHeat/) +``` +Run: `node --test test/funktionenTiles.test.js test/analyseView.test.js` → FAIL + +- [ ] **Step 2: Verschieben** + +Abschnitt `/* --- Analyse --- */` (Zeile 611 bis Dateiende) und die Analyse-Importe aus `funktionen.js` nach `public/analyse.js`; `esc`/`safeUrl`/`request` nach `fn-util.js`; `analyseOf`/`analyseState`/`pickAnalyseProject` und `analyseSel` nach `analyse-view.js`. `funktionen.js` behält Kacheln und News und setzt beim Klick auf die Analyse-Kachel `analyseSel.projectId` (importiert). Vorher prüfen, wer die Funktionen sonst nutzt: `grep -a -rn "initAnalyse\|renderAnalyse\|analyseSel" public server test`. + +- [ ] **Step 3: `app.js:1920` lädt `/analyse.js`** + +`analyse: () => import('/analyse.js').then((m) => m.initAnalyse(document.getElementById('analyse-body'))),` + +- [ ] **Step 4: Tests grün, Suite grün** + +Run: `npm test` → PASS, 0 fail + +- [ ] **Step 5: Sichtprüfung, dass nichts anders aussieht** + +Run: `node test/ui/analyse.ui.mjs /tmp/claude-0/-root/072dd97e-d807-40b8-b757-afe9463e87f8/scratchpad/ui-task2` +Expected: alle Zeilen `OK`, keine `FAIL`. + +- [ ] **Step 6: Commit** + +```bash +git add public/fn-util.js public/analyse.js public/funktionen.js public/analyse-view.js public/app.js test/funktionenTiles.test.js test/analyseView.test.js +git commit -m "Analyse: Oberfläche in eigenes Modul analyse.js, gemeinsame Helfer in fn-util.js" +``` + +--- + +### Task 3: Reine Funktionen für Vergleich und Klartext + +**Files:** +- Modify: `public/analyse-view.js` +- Create: `test/analyseInsights.test.js` + +**Interfaces:** +- Consumes: Statistik-Form aus Task 1; `esc` aus `./fn-util.js`. +- Produces (alle in `public/analyse-view.js`, ohne DOM): + - `fmtNum(n): string` – `de-DE`, ganzzahlig. + - `fmtDuration(sec): string` – `'1:48'`, `'0:07'`; `null`/ungültig → `'–'`. + - `dayLabel(day): string` – `'2026-09-19'` → `'Sa., 19.09.'`. + - `periodLabel(from, to): string` – `'5. Sep. – 4. Okt.'` (Monatskürzel Jan., Feb., März, Apr., Mai, Juni, Juli, Aug., Sep., Okt., Nov., Dez.); leere Eingabe → `''`. + - `compareFrom(stats): { ok: boolean, from: string }` – `ok` wenn `stats.previous?.complete && stats.previous.totals.pv >= 20`; `from` = `'TT.MM.'` von `firstDay + (2 × range − 1)` Tagen nur wenn `previous` vorhanden, `!complete` und `firstDay`; sonst `''`. + - `delta(cur, prev, { kind = 'count' } = {}): { dir: 'up'|'down'|'flat', text: string, aria: string } | null` – Regeln Spec 2.4; `null`, wenn `cur` oder `prev` keine Zahl ist. + - `contactRate(contacts, visitors): number | null` – Kontakt-Aktionen pro 100 Besucher, eine Nachkommastelle; `null` wenn `visitors < 30`. + - `refName(host): string` – Spec 2.1 (Google, Bing, Instagram, Facebook, DuckDuckGo, Ecosia, sonst Host; `''` → `'Direkt oder unbekannt'`). + - `weekdayStats(series, firstDay): { best: { weekday, name, avg } | null, avg: number, peak: { day, pv } | null } | null` – über Tage ≥ `firstDay` (alle, wenn `''`); `null` bei < 7 solchen Tagen; `best` nur wenn Schnitt ≥ 1,2 × Gesamtschnitt; `peak` nur wenn ≥ 1,8 × Gesamtschnitt. + - `summarize(stats): { lead: string, items: [{ icon: 'contact'|'day'|'source'|'device'|'page', html: string }] } | null` – Spec 2.3; `null` bei `totals.pv < 30`; `lead` und `html` sind fertiges, maskiertes HTML mit ``. + - `chartModel(series, opts)` bekommt die Option `compare` (Array `{ pv }`): gemeinsame Skala über beide Reihen, zusätzliches Feld `prevLine` (Pfad, `''` ohne `compare`), `points[i].prev` (Zahl oder `null`). + +- [ ] **Step 1: Failing Tests schreiben** (`test/analyseInsights.test.js`, Testdaten-Helfer `stats(over)` baut eine vollständige Antwort mit 30 Tagen) + +```js +test('delta: Prozent ab 100, sonst absolut, flat, Sekunden, null', () => { + assert.deepEqual(delta(6930, 6080), { dir: 'up', text: '▲ 14 %', aria: 'plus 14 Prozent gegenüber dem Zeitraum davor' }) + assert.equal(delta(188, 143).text, '▲ 31 %') // beide ≥ 100 → Prozent + assert.equal(delta(45, 33).text, '▲ 12') + assert.equal(delta(30, 33).text, '▼ 3') + assert.deepEqual(delta(7, 7), { dir: 'flat', text: '± 0', aria: 'unverändert gegenüber dem Zeitraum davor' }) + assert.equal(delta(1004, 1000).text, '± 0 %') // |0,4 %| < 1 + assert.equal(delta(108, 114, { kind: 'sec' }).text, '▼ 6 s') + assert.equal(delta(200, 135, { kind: 'sec' }).text, '▲ 1:05') + assert.equal(delta(null, 3), null) +}) +test('fmtDuration, dayLabel, periodLabel, fmtNum', () => { + assert.equal(fmtDuration(108), '1:48'); assert.equal(fmtDuration(7), '0:07'); assert.equal(fmtDuration(null), '–') + assert.equal(dayLabel('2026-09-19'), 'Sa., 19.09.') + assert.equal(periodLabel('2026-09-05', '2026-10-04'), '5. Sep. – 4. Okt.') + assert.equal(fmtNum(6930), '6.930') +}) +test('compareFrom: ok, Datum bei neuer Website, nichts ohne previous', () => { + assert.deepEqual(compareFrom(stats()), { ok: true, from: '' }) + assert.deepEqual(compareFrom(stats({ firstDay: '2026-09-20', previous: { ...prev, complete: false } })), { ok: false, from: '18.11.' }) // range 30: +59 Tage + assert.deepEqual(compareFrom({ totals: { pv: 50 }, series: [] }), { ok: false, from: '' }) + assert.equal(compareFrom(stats({ previous: { ...prev, totals: { ...prev.totals, pv: 19 } } })).ok, false) +}) +test('contactRate und refName', () => { + assert.equal(contactRate(188, 2410), 7.8); assert.equal(contactRate(5, 29), null) + assert.equal(refName('www.google.de'), 'Google'); assert.equal(refName('l.facebook.com'), 'Facebook'); assert.equal(refName(''), 'Direkt oder unbekannt'); assert.equal(refName('blog.example'), 'blog.example') +}) +test('weekdayStats: stärkster Wochentag, Spitzentag, < 7 Tage null', () => { /* Samstage 2× Schnitt → best.name 'Samstag'; ein Tag 3× Schnitt → peak */ }) +test('summarize: Wachstum mit Prozent', () => { + const s = summarize(stats()) + assert.equal(s.lead, 'Deine Website wurde in den letzten 30 Tagen 6.930-mal aufgerufen – 14 % mehr als in den 30 Tagen davor.') + assert.equal(s.items[0].icon, 'contact') + assert.equal(s.items[0].html, '188-mal hat dich jemand direkt kontaktiert, meist per Telefon (96). Das sind 45 mehr als zuvor.') + assert.ok(s.items.length <= 4) +}) +test('summarize: Rückgang, etwa gleich, kleine Zahlen absolut', () => { /* '– 9 % weniger als', '– etwa so oft wie in den 30 Tagen davor.', '– 12 Aufrufe mehr als' */ }) +test('summarize: ohne Vergleich und neu gestartet', () => { + // previous fehlt: '…in den letzten 30 Tagen X-mal aufgerufen.' + // firstDay im Zeitraum (complete false): 'Seit dem Start der Analyse am 20.09. wurde deine Website X-mal aufgerufen.' +}) +test('summarize: zu wenig Daten → null; keine Kontakte → Hinweis nur ab 30 Besuchern', () => { + assert.equal(summarize(stats({ totals: { ...t, pv: 29 } })), null) + // contacts 0, visitors 40: 'Von den Besuchern mit Zustimmung hat noch niemand über die Website angerufen oder geschrieben.' +}) +test('summarize: Herkunft, Gerät, Seite und Reihenfolge, höchstens 4', () => { + // refs google.com 880 von 2270 → 'Die meisten, die über einen Link kamen, kommen von Google (880).' + // devices m 4590/6930 → '66 % der Aufrufe kommen vom Handy.' + // pages / dann /preise → 'Am meisten angesehen: die Startseite, danach /preise (1.310).' + // alle 5 Bedingungen wahr → Seite fällt weg +}) +test('summarize: Pfade und Hosts werden maskiert', () => { + const s = summarize(stats({ pages: [{ key: '/', count: 900 }], refs: [{ key: 'a"b.de', count: 900 }] })) + assert.ok(!JSON.stringify(s).includes(' { /* top über beide Reihen; ohne compare prevLine '' und bestehender Test unverändert */ }) +``` + +- [ ] **Step 2: Tests laufen, schlagen fehl** + +Run: `node --test test/analyseInsights.test.js` → FAIL (Exporte fehlen) + +- [ ] **Step 3: Funktionen in `public/analyse-view.js` implementieren** (Signaturen oben; `esc` aus `'./fn-util.js'`; Wochentagsnamen Sonntag…Samstag; Spitzentag-Satz „Spitzentag war Sa., 19.09. mit 371 Aufrufen.“ hängt am Wochentag-Hinweis, wenn beides zutrifft) + +- [ ] **Step 4: Tests grün, Suite grün** + +Run: `node --test test/analyseInsights.test.js test/analyseView.test.js && npm test` → PASS + +- [ ] **Step 5: Commit** + +```bash +git add public/analyse-view.js test/analyseInsights.test.js +git commit -m "Analyse: Vergleich zum Vorzeitraum und Klartext-Regeln als reine Funktionen" +``` + +--- + +### Task 4: HTML-Bausteine der Cockpit-Ansicht + +**Files:** +- Create: `public/analyse-cards.js` +- Create: `test/analyseCards.test.js` + +**Interfaces:** +- Consumes: Task 3 (`summarize`, `delta`, `compareFrom`, `contactRate`, `refName`, `fmtDuration`, `fmtNum`, `periodLabel`, `dayLabel`, `chartModel`, `barRows`, `countryName`, `flagEmoji`, `DEVICE_LABELS`), `esc`. +- Produces (alle geben HTML-Strings zurück, ohne DOM; relative Importe): + - `headerHtml({ project, usable, range, showRange, devMode, stamp })` – Kopfleiste `.an-top`; Auswahl `${sites.map((p) => ``).join('')}` + : `${esc(project?.projectName)}` + const seg = showRange + ? `
${RANGES.map((r) => ``).join('')}
` + : '' + return `
${name}${st.text}
${seg}
` +} + +const TABS = [['uebersicht', 'Übersicht'], ['heatmap', 'Heatmap'], ['einstellungen', 'Einstellungen']] + +export function tabsHtml(tab) { + const active = TABS.some(([k]) => k === tab) ? tab : 'uebersicht' + return `
${TABS.map(([k, label]) => ``).join('')}
` +} + +/* --- 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 = `

Das Wichtigste${period ? ` · ${esc(period)}` : ''}

` + if (!s) return `
${head}

Sobald ein paar mehr Besuche da sind, steht hier das Wichtigste über deine Website in zwei, drei Sätzen.

` + // lead und items[].html liefert summarize bereits maskiert + const points = s.items.length ? `
    ${s.items.map((i) => `
  • ${ico(i.icon)}${i.html}
  • `).join('')}
` : '' + return `
${head}

${s.lead}

${points}
` +} + +/** 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 `` +} + +// 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)}vs. ${esc(k.vs)}` : !cmp.ok && cmp.from ? `Vergleich ab ${esc(cmp.from)}` : '' + return `
${k.label}${k.star ? '*' : ''}${esc(k.value)}${k.sub ? `${esc(k.sub)}` : ''}
${foot}
${sparkHtml(trend(stats?.series, field[k.key], k.key === 'time'))}
` + } + return `
${tiles.map(tile).join('')}
` +} + +/* --- 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) => `

Seitenaufrufe pro Tag

${legend}
` + if (!series.length) return `
${head('')}

Noch keine Daten.

` + // 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 = `
${days} Tage${hasPrev ? 'Zeitraum davor' : ''}
` + const dots = m.points.map((p, i) => ``).join('') + return `
${head(legend)}
${esc(title)}` + + m.yTicks.map((k) => `${esc(k.label)}`).join('') + + m.xLabels.map((l) => `${esc(l.label)}`).join('') + + `${hasPrev ? `` : ''}${dots}
` +} + +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 = `

Kontakt-Aktionen

${fmtNum(total)}
` + if (total < 1) return `
${head}

Noch keine Kontakt-Aktionen. Sie zählen, wenn jemand auf deiner Website auf Telefonnummer, E-Mail-Adresse oder WhatsApp tippt.

` + 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 `
  • ${ico(key)}${label}${fmtNum(cur)}${chip(before && delta(cur, was))}
  • ` + }).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 ? '' : `

    Auf 100 Besucher kommen ${rateDe(rate)} Kontakt-Aktionen${wasRate === null ? '' : ` (davor ${rateDe(wasRate)})`}.

    ` + return `
    ${head}${rows ? `
      ${rows}
    ` : ''}${quote}
    ` +} + +/* --- 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) ? ` ` : '' + return `
  • ${flag}${esc(r.label)}${fmtNum(r.count)}${share(r.count)}
  • ` + } + const tabs = def.tabs.map(([k, label]) => ``).join('') + const rows = all.length ? `
      ${all.slice(0, expanded ? LONG : SHORT).map(row).join('')}
    ` : '

    Noch keine Daten.

    ' + const more = all.length > SHORT && !expanded ? '' : '' + return `

    ${def.title}

    ${tabs}
    ${rows}${more}
    ` +} + +/* --- 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 = `` + return `

    Datenbasis

    ${ring}

    ${main} Seitenaufrufe zählen wir vollständig; Besucher, Kontakt-Aktionen und Zeit pro Seite (*) nur bei Zustimmung.

    ` +} + +export function heatTeaserHtml() { + return '

    Heatmap

    Sieh auf deiner echten Website, wo geklickt wird – am Computer, Tablet und Handy.

    ' +} + +/** Platzhalter in der Form der echten Kacheln, solange die Auswertung laedt. */ +export function skeletonHtml() { + const card = (cls) => `` + return `
    Auswertung wird geladen
    ${card('an-kpi').repeat(4)}
    ${card('an-chartbox')}${card('an-contacts')}
    ${card('an-rank')}${card('an-rank')}
    ` +} + +export function errorHtml(message) { + return `` +} + +/* --- 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', ` ${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]) => `
  • ${label}
  • `) + return `

    Status

    ${rows.map(([k, v]) => `
    ${k}
    ${v}
    `).join('')}
    ` + + `${links.length ? `

    Unterlagen

      ${links.join('')}
    ` : ''}
    ` +} + +/* --- Zusammenbau der Uebersicht --- */ + +export function overviewHtml(stats) { + return `
    ${summaryHtml(stats)}${kpisHtml(stats)}
    ${chartHtml(stats)}${contactsHtml(stats)}
    ` + + `
    ${rankCardHtml('seen', stats)}${rankCardHtml('who', stats)}
    ${basisHtml(stats)}${heatTeaserHtml()}
    ` + + '

    Länderdaten: IP-Geolocation by DB-IP

    ' +} diff --git a/public/analyse-view.js b/public/analyse-view.js index bc6f612..4e45b76 100644 --- a/public/analyse-view.js +++ b/public/analyse-view.js @@ -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 (), 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 = `${fmtNum(pv)}-mal` + 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}${size} ${more ? 'mehr' : 'weniger'} 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 } +} diff --git a/public/analyse.css b/public/analyse.css new file mode 100644 index 0000000..15e08ef --- /dev/null +++ b/public/analyse.css @@ -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,") 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; } +} diff --git a/public/analyse.js b/public/analyse.js new file mode 100644 index 0000000..d7da4ae --- /dev/null +++ b/public/analyse.js @@ -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 = `

    ${esc(err.message)}

    ` } + +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 = '

    Die Analyse ist für deine Websites noch nicht freigeschaltet.

    ' + 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 = `
    ${headHtml(project, usable, { showRange: active && tab === 'uebersicht' })}${active ? tabsHtml(tab) : ''}
    ` + 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 = '

    Analyse ist abgeschaltet

    Für diese Website ist die Analyse im Moment gesperrt. , dann klären wir das.

    ' + else if (a.state === 'unknown') stage.innerHTML = '

    Die Daten konnten gerade nicht geladen werden.

    ' + else if (!a.eligible) stage.innerHTML = `

    Analyse nicht verfügbar

    ${esc(a.reasonText || 'Für diese Website gerade nicht verfügbar.')}${a.reason === 'kein_abo' ? ` ` : ''}

    ` + 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 ` + ` +} + +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 `` + return `

    Auf der Datenschutz-Zusatzseite Deiner Website erscheinen als Verantwortlicher:

    ${controllerLines(controller).map(esc).join(' · ')}

    Stimmt etwas nicht? Schreib uns im Chat, bevor Du einschaltest.

    ` +} + +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 = ` +
    +

    Analyse für ${esc(project.projectName)} einschalten

    +

    Du siehst, wie oft deine Website aufgerufen wird, woher die Besucher kommen und was sie sich ansehen.

    +

    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.

    +

    Farbe des Banners

    +
    + +
    ${PRESET_COLORS.map((c) => ``).join('')}
    +
    + +
    + ${controllerBlock(controller)} + + +
    +
    ` + 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 = '
    ' + const box = stage.querySelector('#an-heat') + if (!state.stats) { + box.innerHTML = '

    Heatmap

    Lade…

    ' + 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 = `
    ${esc(heatCaption('loading'))}
    ` + 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', ` In neuem Tab öffnen`) + } catch { + /* Text reicht */ + } +} + +function renderAnalyseHeat(box, projectId, pages) { + if (!box) return + if (!pages.length) { + box.innerHTML = '

    Heatmap

    Sobald Besucher zugestimmt haben und Seiten aufgerufen wurden, kannst du hier sehen, wo geklickt und gescrollt wird.

    ' + return + } + if (!heatListening) { + window.addEventListener('message', onHeatMessage) + heatListening = true + } + const big = heatSel.big === 't' ? 't' : 'd' + box.innerHTML = ` +

    Heatmap

    +

    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.

    +
    + +
    ${['d', 't'].map((vp) => ``).join('')}
    +
    +
    +
    +
    +
    ` + 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 = ` +

    Analyse ausschalten

    +

    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.

    + +
    ` + const actions = box.querySelector('#an-off-actions') + const error = box.querySelector('#an-off-error') + box.querySelector('#an-off-ask').addEventListener('click', () => { + actions.innerHTML = 'Wirklich ausschalten? ' + 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 = ` +

    Analyse ausgeschaltet

    +

    Die Website-Analyse ist ausgeschaltet. Ab jetzt werden keine Daten mehr gespeichert.

    +
    ` + 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') + } + }) + }) +} diff --git a/public/app.js b/public/app.js index c9f388b..b9251b8 100644 --- a/public/app.js +++ b/public/app.js @@ -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])' diff --git a/public/dashboard.html b/public/dashboard.html index 0a6a4c7..0401f1d 100644 --- a/public/dashboard.html +++ b/public/dashboard.html @@ -22,7 +22,9 @@ + + @@ -178,7 +180,7 @@

    Lade…

    - +

    Analyse

    @@ -196,6 +198,15 @@

    Lade…

    + +
    +
    +

    Webshop

    +

    Dein eigener Online-Shop auf deiner Website – Zahlungen gehen direkt auf dein Stripe-Konto.

    +
    +

    Lade…

    +
    +
    diff --git a/public/datenschutz.html b/public/datenschutz.html index 06f8a72..9e79dbd 100644 --- a/public/datenschutz.html +++ b/public/datenschutz.html @@ -100,12 +100,18 @@

    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.

    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.

    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.

    -

    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).

    +

    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).

    + +

    6d. Webshop auf Websites unserer Kunden

    +

    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.

    +

    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.

    +

    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.

    +

    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.

    7. Abos und Zahlung über Stripe

    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.

    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.

    -

    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.

    +

    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.

    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.

    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.

    Ü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.

    @@ -129,7 +135,7 @@
    • Hetzner Online GmbH: Hosting, E-Mail und Datensicherung (Auftragsverarbeiter)
    • UptimeRobot s. r. o.: Überwachung der Erreichbarkeit, Daten auch außerhalb der EU (insbesondere USA), Auftragsverarbeiter (Abschnitt 3)
    • -
    • Stripe: Zahlungsabwicklung (Abschnitt 7)
    • +
    • Stripe: Zahlungsabwicklung und Eröffnung des Stripe-Kontos für den Webshop unserer Kunden (Abschnitte 6d und 7)
    • Porkbun LLC: bisheriger E-Mail-Dienst, nur noch bis zur Löschung älterer E-Mails (Abschnitt 8)
    • Domain-Registrar und Vergabestellen, sofern wir eine Domain für Sie registrieren (Abschnitt 10)
    • Steuerberatung und Behörden, soweit gesetzlich erforderlich
    • diff --git a/public/embed/shop-demo.html b/public/embed/shop-demo.html new file mode 100644 index 0000000..cd8caa3 --- /dev/null +++ b/public/embed/shop-demo.html @@ -0,0 +1,33 @@ + + + + + + +Webshop Testseite + + + +
      Testseite der Kundenwebsite (intern, nicht verlinkt)
      +
      +

      Unser Online-Shop

      + + +
      +

      Unser Shop ist gerade nicht erreichbar. Bestellungen telefonisch unter 06301 1234.

      +
      +
      +
      Fußbereich
      + + + diff --git a/public/embed/shop.js b/public/embed/shop.js new file mode 100644 index 0000000..f5600c6 --- /dev/null +++ b/public/embed/shop.js @@ -0,0 +1,1792 @@ +/*! WEBklar Webshop: Einbau-Skript fuer Kundenwebsites. + *
      Ausweichtext
      + * data-ansicht="widerruf" oeffnet gleich die Widerrufsfunktion ("Vertrag widerrufen"); die Seitenadresse kennt ?ansicht=widerruf und ?ansicht=agb. + * Keine Abhaengigkeiten, keine Cookies, keine Drittanfragen. Einziger Browser-Speicher: der Warenkorb (nur Artikel-IDs und Mengen) in der + * sessionStorage des Besuchers, solange die Seite im Tab geoeffnet ist (technisch erforderlich, kein Tracking). Alle Texte der Haendler nur ueber + * textContent. Das Widget rechnet nie: Preise, Summen, Versand, Steuerhinweise und Datumstexte kommen als fertige Texte vom Server. */ +(function () { + 'use strict' + var SELF = document.currentScript + var STYLE_ID = 'wks-style' + var API_PFAD = '/api/public/shop/v1/' + var CONFIG_ALTER_MAX_MS = 100 * 60 * 1000 // vor dem Ablauf des Formular-Tokens (2 h) neu laden + var TIMEOUT_MS = 20000 + var FORM_MIN_VORGABE_S = 3 // so lange muss ein Formular-Token mindestens alt sein (wie der Server) + var FORM_PUFFER_MS = 500 // Sicherheitszuschlag zu FORM_MIN_VORGABE_S (Laufzeit der Anfrage) + var MAX_MENGE = 99 + var MAX_POSITIONEN = 50 + var STATUS_VERSUCHE = 5 // Abrufe des Bestellstatus nach der Rueckkehr von Stripe, solange die Bestellung "offen" ist + var STATUS_ABSTAND_MS = 2000 + var PREFIX = 'wks' + Math.random().toString(36).slice(2, 6) + '-' + var zaehler = 0 + var EMAIL_FORM = /^[^\s@,;<>"]+@[^\s@,;<>"]+\.[^\s@,;<>"]+$/ + var ID_FORM = /^[A-Za-z0-9][A-Za-z0-9._-]{0,35}$/ + var TOKEN_FORM = /^[A-Za-z0-9._~-]{8,300}$/ + var LAENDER = { DE: 'Deutschland', AT: 'Österreich' } + var rueckkehr = null // Rueckkehr von Stripe (wk_bestellung/wk_t oder wk_abbruch): der erste Container, der startet, verbraucht sie + var queryAnsicht = '' + + var CSS = [ + '@layer wks{', + '.wks-root{--wks-i-akzent:var(--wks-akzent,var(--wks-vorgabe-akzent,#1d4ed8));--wks-i-akzent-text:var(--wks-akzent-text,var(--wks-vorgabe-akzent-text,#fff));', + '--wks-i-bg:var(--wks-hintergrund,#fff);--wks-i-text:var(--wks-text,#1f2937);--wks-i-rand:var(--wks-rand,#858f9e);--wks-i-radius:var(--wks-radius,10px);', + '--wks-i-weich:#f3f4f6;--wks-i-fehler:#b91c1c;--wks-i-schrift:var(--wks-schrift,inherit);--wks-i-titel:var(--wks-schrift-titel,var(--wks-schrift,inherit))}', + '@supports (color:color-mix(in srgb,red,blue)){.wks-root{--wks-i-weich:color-mix(in srgb,var(--wks-i-text) 6%,var(--wks-i-bg))}}', + '.wks-root{container:wks/inline-size;display:block;position:relative;box-sizing:border-box;padding:20px;border:1px solid #c9ced6;border-radius:var(--wks-i-radius);', + 'background:var(--wks-i-bg);color:var(--wks-i-text);font-family:var(--wks-i-schrift);font-size:16px;line-height:1.5;text-align:left}', + '.wks-root *,.wks-root *::before,.wks-root *::after{box-sizing:border-box}', + '.wks-root [hidden]{display:none!important}', + '.wks-vh{position:absolute;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;clip:rect(0 0 0 0);clip-path:inset(50%);white-space:nowrap;border:0}', + '.wks-kopfzeile{display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;gap:.5rem 1rem;margin:0 0 .25rem}', + '.wks-shopname{font-family:var(--wks-i-titel);font-size:1.5rem;line-height:1.2;font-weight:700;margin:0;color:inherit}', + '.wks-lieferinfo{margin:0 0 .75rem;font-size:.9rem;opacity:.9}', + '.wks-titel{font-family:var(--wks-i-titel);font-size:1.3rem;line-height:1.25;font-weight:700;margin:.5rem 0 .75rem;color:inherit}', + '.wks-titel:focus{outline:none}.wks-titel:focus-visible{outline:3px solid var(--wks-i-text);outline-offset:3px}', + '.wks-untertitel{font-size:1.05rem;font-weight:700;margin:1rem 0 .35rem;color:inherit}', + '.wks-root p{margin:0 0 .75rem}', + '.wks-laden{opacity:.8}', + '.wks-btn{font:inherit;font-weight:600;min-height:44px;padding:.55rem 1.25rem;border-radius:var(--wks-i-radius);border:2px solid var(--wks-i-akzent);background:var(--wks-i-akzent);color:var(--wks-i-akzent-text);cursor:pointer;line-height:1.25}', + '.wks-btn:hover{filter:brightness(1.08)}', + '.wks-btn-sek{background:transparent;color:inherit;border-color:var(--wks-i-rand)}', + '.wks-btn[disabled]{opacity:.6;cursor:default}', + '.wks-link{font:inherit;color:inherit;background:none;border:0;padding:.2rem 0;min-height:24px;text-decoration:underline;cursor:pointer;text-align:left}', + '.wks-nav{display:flex;flex-wrap:wrap;gap:.75rem;justify-content:space-between;align-items:center;margin-top:1.25rem}', + '.wks-nav>:only-child{margin-left:auto}', + '.wks-root :focus-visible{outline:3px solid var(--wks-i-text);outline-offset:2px}', + '.wks-chips{display:flex;flex-wrap:wrap;gap:.5rem;margin:0 0 1rem}', + '.wks-chip{font:inherit;min-height:44px;padding:.4rem 1rem;border-radius:999px;border:1px solid var(--wks-i-rand);background:var(--wks-i-bg);color:inherit;cursor:pointer}', + '.wks-chip[aria-pressed="true"]{background:var(--wks-i-akzent);color:var(--wks-i-akzent-text);border-color:var(--wks-i-akzent);font-weight:600}', + '.wks-raster{list-style:none;margin:0 0 1rem;padding:0;display:grid;grid-template-columns:repeat(auto-fill,minmax(11rem,1fr));gap:1rem}', + '.wks-raster>li{margin:0;padding:0}', + '.wks-karte{position:relative;display:flex;flex-direction:column;gap:.3rem;height:100%;border:1px solid var(--wks-i-rand);border-radius:var(--wks-i-radius);padding:.6rem;background:var(--wks-i-bg)}', + '.wks-karte:hover{border-color:var(--wks-i-akzent)}', + '.wks-karte:focus-within{outline:3px solid var(--wks-i-text);outline-offset:2px}', + '.wks-karte-bild,.wks-karte-leer{display:block;width:100%;aspect-ratio:1/1;object-fit:cover;border-radius:calc(var(--wks-i-radius) * .7);background:var(--wks-i-weich)}', + '.wks-karte-name{margin:0;font-size:1rem;line-height:1.3}', + '.wks-karte-knopf{font:inherit;font-weight:700;text-align:left;background:none;border:0;padding:0;color:inherit;cursor:pointer}', + '.wks-karte-knopf::after{content:"";position:absolute;inset:0}', + '.wks-karte-knopf:focus-visible{outline:none}', + '.wks-karte-text{font-size:.9rem;margin:0}', + '.wks-preis{font-weight:700;margin:0}', + '.wks-grundpreis,.wks-klein{font-size:.85rem;opacity:.85;margin:0}', + '.wks-aus{font-size:.85rem;font-weight:600;color:var(--wks-i-fehler);margin:0}', + '.wks-preis-versand{margin:.1rem 0 .35rem;font-size:.85rem}', + '.wks-hinweiszeile{font-size:.85rem;margin:0 0 .75rem;opacity:.9}', + '.wks-detail{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr);gap:1.25rem;margin:0 0 1rem}', + '.wks-galerie-gross{display:block;width:100%;aspect-ratio:1/1;object-fit:contain;border:1px solid var(--wks-i-rand);border-radius:var(--wks-i-radius);background:var(--wks-i-weich)}', + '.wks-daumen{display:flex;flex-wrap:wrap;gap:.5rem;margin-top:.5rem}', + '.wks-daumen button{width:4rem;height:4rem;min-width:44px;min-height:44px;padding:0;border:2px solid var(--wks-i-rand);border-radius:calc(var(--wks-i-radius) * .7);background:var(--wks-i-bg);cursor:pointer;overflow:hidden}', + '.wks-daumen button[aria-pressed="true"]{border-color:var(--wks-i-akzent)}', + '.wks-daumen img{display:block;width:100%;height:100%;object-fit:cover}', + '.wks-beschreibung p,.wks-text p{white-space:pre-line}', + '.wks-form{margin:0}', + '.wks-feld{margin:0 0 1rem;min-width:0}', + 'fieldset.wks-feld{border:1px solid var(--wks-i-rand);border-radius:var(--wks-i-radius);padding:.5rem 1rem .75rem}', + '.wks-feld legend{font-weight:600;padding:0 .25rem}', + '.wks-label{display:block;font-weight:600;margin-bottom:.25rem}', + '.wks-stern{color:var(--wks-i-fehler)}', + '.wks-hinweis{font-size:.875rem;margin:0 0 .35rem;opacity:.9}', + '.wks-eingabe{display:block;width:100%;min-height:44px;font:inherit;color:inherit;background:var(--wks-i-bg);border:1px solid var(--wks-i-rand);border-radius:calc(var(--wks-i-radius) * .7);padding:.5rem .7rem}', + '.wks-eingabe-menge{width:6rem}', + 'textarea.wks-eingabe{min-height:6rem;resize:vertical}', + '.wks-eingabe[aria-invalid="true"]{border-color:var(--wks-i-fehler);border-width:2px}', + '.wks-feldfehler,.wks-fehlertext{color:var(--wks-i-fehler);font-weight:600;font-size:.9rem;margin:.3rem 0 0}', + '.wks-fehlertext{margin:0 0 .75rem}', + '.wks-meldung{margin:0 0 .75rem;padding:.6rem .8rem;border-left:4px solid var(--wks-i-akzent);background:var(--wks-i-weich);font-weight:600}', + '.wks-radio{display:flex;gap:.65rem;align-items:flex-start;margin:0 0 .5rem;min-height:44px}', + '.wks-radio input{flex:none;width:1.4rem;height:1.4rem;margin:.35rem 0 0;accent-color:var(--wks-i-akzent)}', + '.wks-radio label{cursor:pointer;padding-top:.2rem}', + '.wks-zeilen{list-style:none;margin:0 0 1rem;padding:0;display:grid;gap:.6rem}', + '.wks-zeilen>li{margin:0;padding:.7rem .9rem;border:1px solid var(--wks-i-rand);border-radius:var(--wks-i-radius);display:flex;flex-wrap:wrap;gap:.5rem 1rem;align-items:center;justify-content:space-between}', + '.wks-zeile-name{font-weight:700;margin:0}', + '.wks-zeile-info{flex:1 1 12rem;min-width:0}', + '.wks-zeile-menge{display:flex;align-items:center;gap:.5rem}', + '.wks-zeile-menge label{font-size:.9rem}', + '.wks-pflicht{margin:1.25rem 0;padding:.9rem 1rem;border:2px solid var(--wks-i-akzent);border-radius:var(--wks-i-radius);background:var(--wks-i-bg)}', + '.wks-pflicht>:last-child{margin-bottom:0}', + '.wks-pflicht h4{font-size:1rem;margin:.9rem 0 .25rem}', + '.wks-pflicht h4:first-child{margin-top:0}', + '.wks-pos{list-style:none;margin:0 0 .75rem;padding:0;display:grid;gap:.5rem}', + '.wks-pos>li{display:flex;flex-wrap:wrap;justify-content:space-between;gap:.25rem 1rem;margin:0}', + '.wks-pos-text{flex:1 1 12rem;min-width:0;overflow-wrap:anywhere}', + '.wks-summen{margin:0 0 .75rem;display:grid;grid-template-columns:1fr max-content;gap:.25rem 1rem}', + '.wks-summen dt{margin:0}.wks-summen dd{margin:0;text-align:right}', + '.wks-summen .wks-gesamt{font-weight:700;font-size:1.1rem;border-top:1px solid var(--wks-i-rand);padding-top:.35rem}', + '.wks-zusammen{margin:0 0 1rem;display:grid;grid-template-columns:max-content 1fr;gap:.35rem 1rem}', + '.wks-zusammen dt{font-weight:600}.wks-zusammen dd{margin:0;overflow-wrap:anywhere;white-space:pre-line}', + '.wks-gewaehr{margin:1.25rem 0;padding:.9rem 1rem;border:2px solid var(--wks-i-akzent);border-radius:var(--wks-i-radius);background:var(--wks-i-weich)}', + '.wks-gewaehr h4{font-size:1rem;margin:0 0 .6rem}', + '.wks-gewaehr img{display:block;width:100%;max-width:420px;height:auto;margin:0 0 .6rem;border:1px solid var(--wks-i-rand);border-radius:calc(var(--wks-i-radius) * .7);background:#fff}', + '.wks-gewaehr>:last-child{margin-bottom:0}', + '.wks-agb-hinweis{margin:0 0 .75rem}', + 'dialog.wks-dialog.wks-dialog-gross{width:min(64rem,calc(100vw - 1.5rem))}', + '.wks-dialog-bild{display:block;max-width:100%;height:auto;margin:0 auto 1rem;background:#fff}', + '.wks-links{display:flex;flex-wrap:wrap;gap:.25rem 1.25rem;margin:0 0 .75rem}', + '.wks-box{margin:0 0 1rem;padding:.75rem 1rem;border-radius:var(--wks-i-radius);background:var(--wks-i-weich)}', + '.wks-box h4{font-size:1rem;margin:0 0 .25rem}', + '.wks-box p{white-space:pre-line}', + '.wks-fuss{margin-top:1.5rem;padding-top:.75rem;border-top:1px solid #c9ced6;font-size:.875rem}', + '.wks-fuss-links{display:flex;flex-wrap:wrap;align-items:baseline;gap:.35rem 1.25rem;margin:0 0 .5rem}', + '.wks-fuss a{display:inline-block;padding:.2rem 0;min-height:24px;color:inherit}', + '.wks-anbieter{margin:0 0 .35rem;opacity:.85;overflow-wrap:anywhere}', + '.wks-powered{margin:0;opacity:.75;font-size:.8rem}', + '.wks-ergebnis-text{font-size:1.05rem}', + 'dialog.wks-dialog{position:fixed;inset:auto;left:50%;top:50%;transform:translate(-50%,-50%);margin:0;padding:0;border:0;overflow:auto;width:min(40rem,calc(100vw - 1.5rem));max-height:calc(100vh - 1.5rem);max-height:calc(100dvh - 1.5rem);border-radius:var(--wks-radius,10px);background:var(--wks-hintergrund,#fff);color:var(--wks-text,#1f2937);box-shadow:0 20px 50px rgba(0,0,0,.35)}', + 'dialog.wks-dialog::backdrop{background:rgba(15,23,42,.55)}', + '.wks-dialog-innen{padding:20px;font-family:var(--wks-schrift,inherit);font-size:16px;line-height:1.5;text-align:left}', + '.wks-dialog-innen h3{font-size:1.2rem;margin:0 3rem .75rem 0}', + '.wks-dialog-text{font-size:.95rem}', + '.wks-dialog-text p{white-space:pre-line;margin:0 0 .75rem}', + '.wks-schliessen{position:absolute;top:.5rem;right:.5rem;z-index:1;font:inherit;font-size:1.5rem;line-height:1;min-width:44px;min-height:44px;border-radius:var(--wks-i-radius,10px);border:1px solid #858f9e;background:#fff;color:#1f2937;cursor:pointer}', + '@container wks (max-width:34rem){', + '.wks-detail{grid-template-columns:minmax(0,1fr)}', + '.wks-nav{flex-direction:column-reverse;align-items:stretch}.wks-nav .wks-btn{width:100%}', + '.wks-zusammen{grid-template-columns:1fr;gap:0}.wks-zusammen dd{margin-bottom:.5rem}', + '.wks-raster{grid-template-columns:repeat(auto-fill,minmax(9rem,1fr));gap:.6rem}', + '}', + '@media (max-width:30rem){.wks-root{padding:16px}}', + '@media (forced-colors:active){.wks-chip[aria-pressed="true"],.wks-daumen button[aria-pressed="true"]{outline:2px solid Highlight}}', + '}' + ].join('\n') + + function injectStyle () { + if (document.getElementById(STYLE_ID)) return + var st = document.createElement('style') + st.id = STYLE_ID + st.textContent = CSS + document.head.appendChild(st) + } + + function el (tag, cls, text) { + var e = document.createElement(tag) + if (cls) e.className = cls + if (text != null) e.textContent = text + return e + } + + function knopf (cls, text, onClick) { + var b = el('button', cls, text) + b.type = 'button' + if (onClick) b.addEventListener('click', onClick) + return b + } + + function httpsUrl (u) { + if (typeof u !== 'string') return '' + try { + var p = new URL(u) + return p.protocol === 'https:' ? p.href : '' + } catch (e) { return '' } + } + + function istText (v) { return typeof v === 'string' } + function nichtLeer (v) { return typeof v === 'string' && v.replace(/\s+/g, '') !== '' } + function istObjekt (v) { return v !== null && typeof v === 'object' && !Array.isArray(v) } + function liste (v) { return Array.isArray(v) ? v : [] } + function knoten (nl) { return Array.prototype.slice.call(nl) } + + // Pfade des Servers (ohne Host) bekommen die API-Adresse vorangestellt; nur Pfade, die mit genau einem / beginnen + function absUrl (api, pfad) { + if (!istText(pfad) || pfad.charAt(0) !== '/' || pfad.charAt(1) === '/') return '' + return api + pfad + } + + function apiBasis (root) { + var a = String(root.getAttribute('data-api') || '').replace(/\/+$/, '') + if (a) return a + var s = SELF && SELF.src ? SELF : document.querySelector('script[src*="/embed/shop.js"]') + if (s && s.src) { + try { return new URL(s.src).origin } catch (e) { return '' } + } + return '' + } + + function queryWert (name) { + var m = new RegExp('[?&]' + name + '=([^&#]*)').exec(location.search || '') + if (!m) return '' + try { return decodeURIComponent(m[1].replace(/\+/g, ' ')) } catch (e) { return '' } + } + + // Parameter aus der Adresse der Seite entfernen, ohne neu zu laden (nach der Rueckkehr von Stripe) + function bereinigeUrl (namen) { + try { + var rest = String(location.search || '').replace(/^\?/, '').split('&').filter(function (p) { + return p !== '' && namen.indexOf(p.split('=')[0]) < 0 + }).join('&') + history.replaceState(history.state, '', location.pathname + (rest ? '?' + rest : '') + (location.hash || '')) + } catch (e) { /* Adresse bleibt, wie sie ist */ } + } + + // @farben-anfang (reine Funktionen wie in embed/termine.js) + // Relative Leuchtdichte nach WCAG 2.1 fuer '#rrggbb'. + function luminanz (farbe) { + var rgb = [1, 3, 5].map(function (i) { + var c = parseInt(farbe.slice(i, i + 2), 16) / 255 + return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4) + }) + return 0.2126 * rgb[0] + 0.7152 * rgb[1] + 0.0722 * rgb[2] + } + + // Kontrastverhaeltnis zweier Leuchtdichten (1 bis 21). + function kontrast (a, b) { + return (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05) + } + + // Schrift auf der Akzentfarbe: Weiss oder das dunkle Grau, je nachdem was mehr Kontrast gibt; erreicht keines 4,5 : 1, nimmt sie Schwarz. + function textFarbeAuf (farbe) { + var l = luminanz(farbe) + var weiss = kontrast(l, 1) + var dunkel = kontrast(l, luminanz('#111827')) + var beste = Math.max(weiss, dunkel) + if (beste < 4.5 && kontrast(l, 0) > beste) return '#000000' + return weiss >= dunkel ? '#ffffff' : '#111827' + } + // @farben-ende + + // Akzentfarbe des Shops (#rrggbb), wenn die Seite --wks-akzent nicht selbst setzt + function setzeAkzent (box, farbe) { + if (!/^#[0-9a-fA-F]{6}$/.test(farbe || '')) return + if (getComputedStyle(box).getPropertyValue('--wks-akzent').trim()) return + box.style.setProperty('--wks-vorgabe-akzent', farbe) + box.style.setProperty('--wks-vorgabe-akzent-text', textFarbeAuf(farbe)) + } + + // ---------------------------------------------------------------- Warenkorb im Browser (nur IDs und Mengen) + + function korbSchluessel (key) { return 'wks-korb-' + key } + + function korbLesen (key) { + var aus = [] + try { + var roh = window.sessionStorage.getItem(korbSchluessel(key)) + var arr = roh ? JSON.parse(roh) : [] + if (Array.isArray(arr)) { + arr.slice(0, MAX_POSITIONEN).forEach(function (z) { + if (!Array.isArray(z) || z.length !== 3 || !istText(z[0]) || !ID_FORM.test(z[0]) || !istText(z[1]) || (z[1] !== '' && !ID_FORM.test(z[1]))) return + var n = z[2] + if (typeof n !== 'number' || n % 1 !== 0 || n < 1) return + aus.push({ id: z[0], v: z[1], n: Math.min(n, MAX_MENGE) }) + }) + } + } catch (e) { aus = [] } + return aus + } + + function korbSchreiben (key, korb) { + try { + if (korb.length === 0) window.sessionStorage.removeItem(korbSchluessel(key)) + else window.sessionStorage.setItem(korbSchluessel(key), JSON.stringify(korb.map(function (z) { return [z.id, z.v, z.n] }))) + } catch (e) { /* ohne Speicher bleibt der Warenkorb im Speicher der Seite */ } + } + + // Zeilen eines Textes: Absaetze sind durch Leerzeilen getrennt + function absaetze (text, cls) { + var box = el('div', cls) + String(text).split(/\n{2,}/).forEach(function (a) { if (a.trim()) box.appendChild(el('p', '', a.trim())) }) + return box + } + + function adresseText (a) { + if (!istObjekt(a)) return '' + var land = istText(a.land) ? (LAENDER[a.land] || a.land) : '' + return [a.name, a.strasse, [a.plz, a.ort].filter(nichtLeer).join(' '), land].filter(nichtLeer).join(', ') + } + + // ---------------------------------------------------------------- Ein Shop + + function init (root) { + var key = root.getAttribute('data-webklar-shop') + if (!key || root.getAttribute('data-wks-init')) return + root.setAttribute('data-wks-init', '1') + var api = apiBasis(root) + var id = PREFIX + (++zaehler) + '-' + var ausweich = null // Ausweichtext der Seite (Kindknoten des Containers), solange das Widget ihn ersetzt + var dlg = null + var dlgAusloeser = null + var ui = { box: null, live: null, kopf: null, body: null, fuss: null, titel: null, statusBox: null } + var S = null + + function neuerZustand () { + return { + cfg: S ? S.cfg : null, + cfgAt: S ? S.cfgAt : 0, + korb: S ? S.korb : [], + ansicht: '', + vorWiderruf: '', + kategorie: '', + produktId: '', + auswahl: { v: '', n: 1 }, + bild: 0, + lieferart: '', + land: '', + pruef: null, + pruefFehler: null, + pruefSeq: 0, + honig: '', + fehler: {}, + meldung: '', + sendet: false, + tot: false, + fokusGesetzt: false, + seq: 0, + statusSeq: 0, + status: null, + wd: { name: '', email: '', bestellnummer: '', nachricht: '' }, + wdSchritt: 'form', + wdEingang: '' + } + } + S = neuerZustand() + + function fid (name) { return id + String(name).replace(/[^a-z0-9_-]/gi, '-') } + + // ------------------------------------------------------------ Anfragen + + function request (method, pfad, body, frisch) { + var opts = { method: method, credentials: 'omit', headers: { Accept: 'application/json' } } + if (frisch) opts.cache = 'reload' // Konfiguration erneuern: nicht aus dem Browser-Cache (dort steht das alte Formular-Token) + if (body !== undefined) { + opts.headers['Content-Type'] = 'application/json' + opts.body = JSON.stringify(body) + } + var ctrl = typeof AbortController === 'function' ? new AbortController() : null + var timer = null + if (ctrl) { + opts.signal = ctrl.signal + timer = setTimeout(function () { ctrl.abort() }, TIMEOUT_MS) + } + function fertig () { if (timer) clearTimeout(timer) } + return fetch(api + API_PFAD + encodeURIComponent(key) + pfad, opts).then(function (r) { + return r.json().then(function (d) { return { status: r.status, ok: r.ok, body: d } }, function () { return { status: r.status, ok: r.ok, body: null } }) + }, function () { + throw { netz: true } + }).then(function (r) { + fertig() + if (r.ok && r.body !== null && typeof r.body === 'object') return r.body + var fehler = {} + var b = r.body + if (b && typeof b.fehler === 'object' && b.fehler) { + Object.keys(b.fehler).forEach(function (k) { if (istText(b.fehler[k])) fehler[k] = b.fehler[k] }) + } + throw { status: r.status, fehler: fehler, code: b && istText(b.code) ? b.code : '', produktId: b && istText(b.produktId) ? b.produktId : '', netz: r.status >= 500 || r.status === 403 || r.status === 404 || r.status === 0 } + }, function (err) { + fertig() + throw err + }) + } + + // ------------------------------------------------------------ Konfiguration + + // Fail closed: fehlt etwas, das der Shop zwingend braucht, gilt er als nicht aktiv (der Ausweichtext der Seite bleibt) + function konfigGueltig (d) { + if (!d || d.aktiv !== true || !istObjekt(d.shop) || !nichtLeer(d.shop.name) || !nichtLeer(d.formToken)) return false + if (d.nurWiderruf === true) return true + if (typeof d.offen !== 'boolean' || !Array.isArray(d.produkte) || !istObjekt(d.shop.anbieter)) return false + return d.produkte.every(function (p) { + return istObjekt(p) && nichtLeer(p.id) && ID_FORM.test(p.id) && istText(p.name) && istText(p.preisText) + }) + } + + function lade (neu) { + return request('GET', '', undefined, neu).then(function (d) { + if (!konfigGueltig(d)) throw { inaktiv: true } + S.cfg = d + S.cfgAt = performance.now() + return d + }) + } + + function configFrisch () { return !!S.cfg && performance.now() - S.cfgAt <= CONFIG_ALTER_MAX_MS } + + // Alte Konfiguration (das Formular-Token laeuft nach 2 h ab) erneuern. Aufgeloest wird mit true, wenn neu geladen wurde. + // Ist der Server nicht erreichbar, geht es mit der alten Konfiguration weiter: der naechste Aufruf meldet es. + function sichereConfig () { + if (configFrisch()) return Promise.resolve(false) + return lade(true).then(function () { bereinigeKorb(); return true }, function (err) { + if (err && err.inaktiv) { zeigeAusweich(); throw err } + return false + }) + } + + // Der Server speichert nichts (und antwortet doch mit Erfolg), wenn das Formular-Token juenger als 3 Sekunden ist: das Widget wartet vor dem + // Absenden, bis es alt genug ist (gemessen ab Eintreffen der Konfiguration; das Token ist beim Server immer etwas aelter). + function formWartezeitMs () { + return Math.max(0, FORM_MIN_VORGABE_S * 1000 + FORM_PUFFER_MS - (performance.now() - S.cfgAt)) + } + + // true = Token alt genug; false = die Ansicht hat sich inzwischen geaendert: nicht senden. + function warteAufFormToken (senden) { + var ms = formWartezeitMs() + if (ms <= 0) return Promise.resolve(true) + var zustand = S + var seq = S.seq + var beschriftung = senden.textContent + senden.textContent = 'Einen Moment …' + ansage('Einen Moment …') + return new Promise(function (resolve) { + setTimeout(function () { + if (S !== zustand || S.seq !== seq || S.tot) { zustand.sendet = false; resolve(false); return } + senden.textContent = beschriftung + resolve(true) + }, ms) + }) + } + + // ------------------------------------------------------------ Daten des Shops + + function shop () { return S.cfg.shop } + function nurWiderruf () { return S.cfg.nurWiderruf === true } + function rechtstexte () { return istObjekt(S.cfg.rechtstexte) ? S.cfg.rechtstexte : null } + function gewaehr () { + var g = S.cfg.gewaehrleistung + return istObjekt(g) && nichtLeer(g.text) ? g : null + } + // Bestellen ist nur moeglich, wenn der Shop geoeffnet ist und die bestaetigten Rechtstexte samt Gewaehrleistungshinweis vorliegen + function bestellbar () { + var r = rechtstexte() + return !nurWiderruf() && S.cfg.offen === true && !!r && nichtLeer(r.agb) && nichtLeer(r.widerrufsbelehrung) && !!gewaehr() + } + function produktMitId (pid) { + var p = liste(S.cfg.produkte) + for (var i = 0; i < p.length; i++) if (p[i].id === pid) return p[i] + return null + } + function varianteMitId (p, vid) { + var w = p && istObjekt(p.varianten) ? liste(p.varianten.werte) : [] + for (var i = 0; i < w.length; i++) if (w[i].id === vid) return w[i] + return null + } + function hatVarianten (p) { return !!p && istObjekt(p.varianten) && liste(p.varianten.werte).length > 0 } + function anzahlImKorb () { return S.korb.reduce(function (s, z) { return s + z.n }, 0) } + function speichereKorb () { korbSchreiben(key, S.korb) } + + // Positionen, die es nicht (mehr) gibt, fliegen aus dem Warenkorb; gibt die Anzahl entfernter Positionen zurueck + function bereinigeKorb () { + var vorher = S.korb.length + S.korb = S.korb.filter(function (z) { + var p = produktMitId(z.id) + if (!p) return false + if (hatVarianten(p)) return !!varianteMitId(p, z.v) + return z.v === '' + }) + var weg = vorher - S.korb.length + if (weg > 0) { + speichereKorb() + S.korbHinweis = true + } + return weg + } + + function lieferInfoText () { + var s = shop() + var teile = [] + var laender = liste(s.lieferlaender).map(function (l) { return istObjekt(l) ? l.name : '' }).filter(nichtLeer) + if (laender.length) teile.push('Lieferung nach: ' + laender.join(', ')) + if (istObjekt(s.abholung) && s.abholung.aktiv === true) teile.push('Abholung möglich') + if (nichtLeer(s.zahlungsarten)) teile.push('Zahlung: ' + s.zahlungsarten) + return teile.join(' · ') + } + + function anbieterZeile () { + var a = shop().anbieter + if (!istObjekt(a)) return '' + var teile = [adresseText(a)] + if (nichtLeer(a.email)) teile.push('E-Mail: ' + a.email) + if (nichtLeer(a.telefon)) teile.push('Telefon: ' + a.telefon) + return teile.filter(nichtLeer).join(', ') + } + + // ------------------------------------------------------------ Geruest + + function bauGeruest (box) { + box.classList.add('wks-root') + var live = el('div', 'wks-vh') + live.setAttribute('aria-live', 'polite') + live.setAttribute('aria-atomic', 'true') + var kopf = el('div', 'wks-kopf') + var body = el('div', 'wks-body') + var fuss = el('div', 'wks-fuss') + box.appendChild(live) + box.appendChild(kopf) + box.appendChild(body) + box.appendChild(fuss) + ui.box = box + ui.live = live + ui.kopf = kopf + ui.body = body + ui.fuss = fuss + } + + function ansage (text) { + if (ui.live) ui.live.textContent = text + } + + function add (node) { ui.body.appendChild(node); return node } + + function titel (text) { + var h = el('h3', 'wks-titel', text) + h.tabIndex = -1 + h.id = fid('titel') + ui.titel = h + return h + } + + function meldungAnzeigen () { + if (S.meldung) { + add(el('p', 'wks-meldung', S.meldung)) + ansage(S.meldung) + } + } + + function inBestellung (a) { return a === 'raster' || a === 'detail' || a === 'korb' || a === 'kasse' } + + function zeichneKopf () { + ui.kopf.textContent = '' + var zeile = el('div', 'wks-kopfzeile') + zeile.appendChild(el('h2', 'wks-shopname', shop().name)) + if (bestellbar() && S.ansicht !== 'status') { + var n = anzahlImKorb() + var kk = knopf('wks-btn wks-btn-sek', 'Warenkorb (' + n + ')', function () { gehe('korb') }) + kk.setAttribute('aria-label', 'Warenkorb, ' + n + ' Stück') + zeile.appendChild(kk) + } + ui.kopf.appendChild(zeile) + if (bestellbar() && inBestellung(S.ansicht)) { + var info = lieferInfoText() + if (info) ui.kopf.appendChild(el('p', 'wks-lieferinfo', info)) + } + } + + function zeichne (fokus) { + S.seq++ + S.fokusGesetzt = false + S.statusSeq++ + ui.body.textContent = '' + ui.titel = null + zeichneKopf() + var f = { + raster: zeichneRaster, detail: zeichneDetail, korb: zeichneKorb, kasse: zeichneKasse, + status: zeichneStatus, widerruf: zeichneWiderruf, geschlossen: zeichneGeschlossen + } + ;(f[S.ansicht] || zeichneRaster)() + zeichneFuss() + if (fokus) { + if (ui.titel && !S.fokusGesetzt) ui.titel.focus() + try { + if (ui.box.getBoundingClientRect().top < 0) ui.box.scrollIntoView({ block: 'start' }) + } catch (e) { /* Scrollen ist nur eine Hilfe */ } + } + } + + function gehe (ansicht, opt) { + opt = opt || {} + if (ansicht === 'widerruf' && S.ansicht !== 'widerruf') S.vorWiderruf = S.ansicht + S.ansicht = ansicht + S.fehler = opt.fehler || {} + S.meldung = opt.meldung || '' + zeichne(opt.fokus !== false) + } + + function startAnsicht () { + if (nurWiderruf()) return 'widerruf' + return bestellbar() ? 'raster' : 'geschlossen' + } + + // Fuss: immer AGB, Widerrufsbelehrung, Versand & Zahlung, Vertrag widerrufen, Impressum, Datenschutz (soweit bekannt) + function zeichneFuss () { + ui.fuss.textContent = '' + var r = rechtstexte() + var links = el('p', 'wks-fuss-links') + if (r && nichtLeer(r.agb)) links.appendChild(knopf('wks-link', 'AGB', function (e) { zeigeAgb(e.currentTarget) })) + if (r && nichtLeer(r.widerrufsbelehrung)) links.appendChild(knopf('wks-link', 'Widerrufsbelehrung', function (e) { zeigeBelehrung(e.currentTarget) })) + if (r && nichtLeer(r.versandUndZahlung)) links.appendChild(knopf('wks-link', 'Versand & Zahlung', function (e) { zeigeVersand(e.currentTarget) })) + if (!nurWiderruf()) links.appendChild(knopf('wks-btn wks-btn-sek', 'Vertrag widerrufen', zeigeWiderruf)) // im Widerrufs-Modus ist das Formular schon die ganze Ansicht + var imp = r ? httpsUrl(r.impressumUrl) : '' + if (imp) links.appendChild(externerLink('Impressum', imp)) + var ds = r ? httpsUrl(r.datenschutzUrl) : '' + if (ds) links.appendChild(externerLink('Datenschutz', ds)) + ui.fuss.appendChild(links) + var anb = anbieterZeile() + if (anb) ui.fuss.appendChild(el('p', 'wks-anbieter', 'Anbieter: ' + anb)) + ui.fuss.appendChild(el('p', 'wks-powered', 'Shop-Technik: WEBklar')) + } + + function externerLink (text, href) { + var a = el('a', '', text) + a.href = href + a.target = '_blank' + a.rel = 'noopener noreferrer' + return a + } + + // Shop nicht erreichbar oder nicht aktiv: der Ausweichtext der Seite bleibt bzw. kommt zurueck + function zeigeAusweich () { + S.tot = true + root.classList.remove('wks-root') + root.textContent = '' + if (ausweich) root.appendChild(ausweich.cloneNode(true)) // Kopie: ein zweiter Aufruf darf den Text nicht verlieren + } + + // Netz weg oder Server kaputt mitten in der Strecke: Meldung und "Erneut versuchen"; nie ein selbststaendiger neuer Abruf + function ausfall (wiederhole, meldung) { + S.sendet = false + ui.body.textContent = '' + ui.titel = null + var p = el('p', 'wks-fehlertext', meldung || 'Gerade nicht erreichbar. Bitte versuchen Sie es später noch einmal.') + p.tabIndex = -1 + add(p) + add(knopf('wks-btn wks-btn-sek', 'Erneut versuchen', function () { wiederhole() })) + ansage(p.textContent) + p.focus() + } + + function meldung429 (err) { + return (err.fehler && err.fehler._) || 'Zu viele Anfragen. Bitte versuchen Sie es später noch einmal.' + } + + // ------------------------------------------------------------ Dialog fuer Rechtstexte + + function dialogOeffnen (ueberschrift, bloecke, ausloeser) { + if (!dlg) { + dlg = el('dialog', 'wks-dialog') + dlg.addEventListener('click', function (e) { if (e.target === dlg) dialogSchliessen() }) + dlg.addEventListener('close', function () { if (dlgAusloeser && dlgAusloeser.focus) dlgAusloeser.focus() }) + // Esc schliesst auch dort, wo showModal fehlt (der Browser erledigt es sonst selbst) + dlg.addEventListener('keydown', function (e) { if (e.key === 'Escape' || e.key === 'Esc') { e.preventDefault(); dialogSchliessen() } }) + root.appendChild(dlg) + } + dlgAusloeser = ausloeser || null + dlg.className = 'wks-dialog' + (bloecke.some(function (b) { return !!b.bild }) ? ' wks-dialog-gross' : '') + dlg.textContent = '' + var innen = el('div', 'wks-dialog-innen') + var zu = knopf('wks-schliessen', '×', dialogSchliessen) + zu.setAttribute('aria-label', 'Schließen') + innen.appendChild(zu) + var h = el('h3', '', ueberschrift) + h.tabIndex = -1 + innen.appendChild(h) + var text = el('div', 'wks-dialog-text') + text.tabIndex = 0 // der Text scrollt: auch mit der Tastatur erreichbar + text.setAttribute('role', 'region') + text.setAttribute('aria-label', ueberschrift) + bloecke.forEach(function (b) { + if (b.bild) { + var gross = el('img', 'wks-dialog-bild') + gross.src = b.bild + gross.alt = nichtLeer(b.text) ? b.text : '' + gross.decoding = 'async' + text.appendChild(gross) + return + } + if (!nichtLeer(b.text)) return + if (b.titel) text.appendChild(el('h4', 'wks-untertitel', b.titel)) + text.appendChild(absaetze(b.text, '')) + }) + innen.appendChild(text) + var nav = el('div', 'wks-nav') + nav.appendChild(knopf('wks-btn wks-btn-sek', 'Schließen', dialogSchliessen)) + innen.appendChild(nav) + dlg.appendChild(innen) + dlg.setAttribute('aria-label', ueberschrift) + if (typeof dlg.showModal === 'function') { + if (!dlg.open) dlg.showModal() + } else { + dlg.setAttribute('open', '') + } + h.focus() + } + + function dialogSchliessen () { + if (!dlg) return + if (typeof dlg.close === 'function') dlg.close() + else dlg.removeAttribute('open') + if (dlgAusloeser && dlgAusloeser.focus) dlgAusloeser.focus() + } + + function zeigeAgb (ausloeser) { + var r = rechtstexte() + if (r) dialogOeffnen('Allgemeine Geschäftsbedingungen', [{ text: r.agb }], ausloeser) + } + function zeigeBelehrung (ausloeser) { + var r = rechtstexte() + if (r) dialogOeffnen('Widerrufsbelehrung', [{ text: r.widerrufsbelehrung }, { titel: 'Muster-Widerrufsformular', text: r.widerrufsformular }], ausloeser) + } + function zeigeVersand (ausloeser) { + var r = rechtstexte() + var s = shop() + var bloecke = [] + if (nichtLeer(s.versandText)) bloecke.push({ titel: 'Versandkosten', text: s.versandText }) + if (nichtLeer(s.mindestbestellText)) bloecke.push({ text: s.mindestbestellText }) + if (nichtLeer(s.lieferzeit)) bloecke.push({ titel: 'Lieferzeit', text: s.lieferzeit }) + if (r && nichtLeer(r.versandUndZahlung)) bloecke.push({ text: r.versandUndZahlung }) + dialogOeffnen('Versand und Zahlung', bloecke, ausloeser) + } + + // Versandhinweis neben dem Preis (PAngV § 6): nur wenn der Shop versendet; reine Abholshops zeigen kein "zzgl. Versand" + function versandAktiv () { return liste(shop().lieferlaender).length > 0 } + function versandLink () { + return versandAktiv() ? knopf('wks-link', 'zzgl. Versand', function (e) { zeigeVersand(e.currentTarget) }) : null + } + + // Hinweis auf das gesetzliche Gewaehrleistungsrecht (Art. 246a § 1 Abs. 1 S. 1 Nr. 11 EGBGB): amtliche Grafik in hervorgehobenem + // Kasten vor der Zusammenfassung der Kasse, sofort geladen, mit dem woertlichen Text als Alternativtext und einer Vergroesserung + function gewaehrKasten () { + var g = gewaehr() + if (!g) return null + var kasten = el('section', 'wks-gewaehr') + kasten.setAttribute('aria-label', 'Ihre gesetzlichen Gewährleistungsrechte') + kasten.appendChild(el('h4', '', 'Ihre gesetzlichen Gewährleistungsrechte')) + var bildUrl = httpsUrl(g.bild) + if (bildUrl) { + var img = el('img') + img.src = bildUrl + img.alt = g.text + img.loading = 'eager' + img.decoding = 'async' + kasten.appendChild(img) + kasten.appendChild(knopf('wks-btn wks-btn-sek', 'Vergrößern', function (e) { + dialogOeffnen('Ihre gesetzlichen Gewährleistungsrechte', [{ bild: bildUrl, text: g.text }], e.currentTarget) + })) + } else { + kasten.appendChild(absaetze(g.text, 'wks-text')) + } + return kasten + } + + // ------------------------------------------------------------ Formularhelfer + + function honigfeld () { + var wrap = el('div', 'wks-hp') + wrap.setAttribute('aria-hidden', 'true') + wrap.style.cssText = 'position:absolute;left:-10000px;top:auto;width:1px;height:1px;overflow:hidden' + var lab = el('label', '', 'Bitte leer lassen') + var inp = document.createElement('input') + inp.type = 'text' + inp.name = 'kontakt_url' + inp.value = S.honig + inp.setAttribute('autocomplete', 'off') + inp.setAttribute('tabindex', '-1') + inp.addEventListener('input', function () { S.honig = inp.value }) + lab.appendChild(inp) + wrap.appendChild(lab) + return wrap + } + + // opt: { name, label, typ (text|email|textarea), wert, pflicht, hinweis, autocomplete, maxlength, onInput }; refs sammelt { name: { input, fehler, beschreibung } } + function feld (refs, opt) { + var wrap = el('div', 'wks-feld') + var fieldId = fid('f-' + opt.name) + var lab = el('label', 'wks-label', opt.label) + lab.htmlFor = fieldId + if (opt.pflicht) { + var st = el('span', 'wks-stern', ' *') + st.setAttribute('aria-hidden', 'true') + lab.appendChild(st) + } else { + lab.appendChild(document.createTextNode(' (freiwillig)')) + } + wrap.appendChild(lab) + var beschreibung = [] + if (opt.hinweis) { + var h = el('p', 'wks-hinweis', opt.hinweis) + h.id = fieldId + '-h' + beschreibung.push(h.id) + wrap.appendChild(h) + } + var inp + if (opt.typ === 'textarea') { + inp = document.createElement('textarea') + inp.rows = 4 + } else { + inp = document.createElement('input') + inp.type = opt.typ || 'text' + } + inp.id = fieldId + inp.name = opt.name + inp.className = 'wks-eingabe' + inp.value = opt.wert || '' + if (opt.pflicht) inp.required = true + if (opt.autocomplete) inp.setAttribute('autocomplete', opt.autocomplete) + if (opt.maxlength) inp.maxLength = opt.maxlength + if (beschreibung.length) inp.setAttribute('aria-describedby', beschreibung.join(' ')) + var fehler = el('p', 'wks-feldfehler') + fehler.id = fieldId + '-e' + fehler.hidden = true + inp.addEventListener('input', function () { opt.onInput(inp.value) }) + inp.addEventListener('change', function () { opt.onInput(inp.value) }) + wrap.appendChild(inp) + wrap.appendChild(fehler) + refs[opt.name] = { input: inp, fehler: fehler, beschreibung: beschreibung } + return wrap + } + + // Fehlertexte an die Felder schreiben; liefert die Meldungen ohne Feld + function zeigeFeldfehler (refs, fehler) { + var uebrig = [] + var erster = null + Object.keys(refs).forEach(function (k) { + var r = refs[k] + var text = fehler[k] + if (text) { + r.fehler.textContent = text + r.fehler.hidden = false + r.input.setAttribute('aria-invalid', 'true') + r.input.setAttribute('aria-describedby', r.beschreibung.concat([r.fehler.id]).join(' ')) + if (!erster) erster = r.input + } else { + r.fehler.textContent = '' + r.fehler.hidden = true + r.input.removeAttribute('aria-invalid') + if (r.beschreibung.length) r.input.setAttribute('aria-describedby', r.beschreibung.join(' ')) + else r.input.removeAttribute('aria-describedby') + } + }) + Object.keys(fehler).forEach(function (k) { if (!refs[k]) uebrig.push(fehler[k]) }) + if (erster) { + S.fokusGesetzt = true + erster.focus() + ansage('Bitte prüfen Sie Ihre Eingaben: ' + Object.keys(fehler).map(function (k) { return fehler[k] }).join(' ')) + } + return uebrig + } + + // Meldungen ohne eigenes Feld in den Fehlerkasten: nie verschlucken + function zeigeRest (box, texte) { + texte.forEach(function (t) { box.appendChild(el('p', 'wks-fehlertext', t)) }) + if (texte.length) ansage(texte.join(' ')) + } + + function mengeFeld (wert, onChange, labelText, idName) { + var wrap = el('div', 'wks-zeile-menge') + var inp = document.createElement('input') + inp.type = 'number' + inp.min = '1' + inp.max = String(MAX_MENGE) + inp.step = '1' + inp.className = 'wks-eingabe wks-eingabe-menge' + inp.id = fid(idName) + inp.value = String(wert) + inp.setAttribute('inputmode', 'numeric') + var lab = el('label', '', labelText) + lab.htmlFor = inp.id + inp.addEventListener('change', function () { + var n = parseInt(inp.value, 10) + if (!(n >= 1)) n = 1 + if (n > MAX_MENGE) n = MAX_MENGE + inp.value = String(n) + onChange(n) + }) + wrap.appendChild(lab) + wrap.appendChild(inp) + return wrap + } + + // ------------------------------------------------------------ Raster + + function preisAnzeige (p) { return nichtLeer(p.preisAbText) ? p.preisAbText : p.preisText } + + function bildElement (p, grosse, cls) { + var b = liste(p.bilder)[0] + var src = istObjekt(b) ? absUrl(api, b[grosse]) : '' + if (!src) return null + var img = el('img', cls) + img.src = src + img.alt = p.name + img.loading = 'lazy' + img.decoding = 'async' + return img + } + + function zeichneRaster () { + var kategorien = liste(S.cfg.kategorien).filter(nichtLeer) + add(titel('Unsere Artikel')) + meldungAnzeigen() + if (S.korbHinweis) { + S.korbHinweis = false + add(el('p', 'wks-meldung', 'Einige Artikel in Ihrem Warenkorb sind nicht mehr verfügbar und wurden entfernt.')) + } + var chips = null + if (kategorien.length > 1) { + chips = el('div', 'wks-chips') + chips.setAttribute('role', 'group') + chips.setAttribute('aria-label', 'Kategorien') + add(chips) + } + var ul = el('ul', 'wks-raster') + var leer = el('p', '', 'Zurzeit sind keine Artikel verfügbar.') + function fuelle () { + ul.textContent = '' + var n = 0 + liste(S.cfg.produkte).forEach(function (p) { + if (S.kategorie && p.kategorie !== S.kategorie) return + n++ + ul.appendChild(karte(p)) + }) + ul.hidden = n === 0 + leer.hidden = n !== 0 + } + if (chips) { + var alle = [''] + kategorien.forEach(function (k) { alle.push(k) }) + alle.forEach(function (k) { + var b = knopf('wks-chip', k || 'Alle', function () { + S.kategorie = k + knoten(chips.childNodes).forEach(function (c) { c.setAttribute('aria-pressed', String(c === b)) }) + fuelle() + ansage(k ? 'Kategorie ' + k : 'Alle Artikel') + }) + b.setAttribute('aria-pressed', String(S.kategorie === k)) + chips.appendChild(b) + }) + } + add(ul) + add(leer) + fuelle() + // Steuerhinweis einmal unter dem Raster; der Versandhinweis steht bei jedem Preis (karte) + if (nichtLeer(shop().steuerHinweisKurz)) add(el('p', 'wks-hinweiszeile', shop().steuerHinweisKurz)) + } + + function karte (p) { + var li = el('li') + var art = el('div', 'wks-karte') + var bild = bildElement(p, 'klein', 'wks-karte-bild') + art.appendChild(bild || el('span', 'wks-karte-leer')) + var h = el('h4', 'wks-karte-name') + var b = knopf('wks-karte-knopf', p.name, function () { zeigeDetail(p.id) }) + h.appendChild(b) + art.appendChild(h) + if (nichtLeer(p.kurz)) art.appendChild(el('p', 'wks-karte-text', p.kurz)) + art.appendChild(el('p', 'wks-preis', preisAnzeige(p))) + if (nichtLeer(p.grundpreisText)) art.appendChild(el('p', 'wks-grundpreis', p.grundpreisText)) + var vl = versandLink() + if (vl) { + var vz = el('p', 'wks-preis-versand') + vz.appendChild(vl) + art.appendChild(vz) + } + if (p.ausverkauft === true || p.verfuegbar === false) art.appendChild(el('p', 'wks-aus', 'Ausverkauft')) + li.appendChild(art) + return li + } + + // ------------------------------------------------------------ Detail + + function zeigeDetail (pid) { + S.produktId = pid + S.auswahl = { v: '', n: 1 } + S.bild = 0 + gehe('detail') + } + + function herstellerZeilen (dl, p) { + var si = istObjekt(p.sicherheit) ? p.sicherheit : {} + var h = istObjekt(si.hersteller) ? si.hersteller : null + function adr (a) { return [a.name, a.anschrift, a.kontakt].filter(nichtLeer).join(', ') } + function zeile (l, w) { if (nichtLeer(w)) { dl.appendChild(el('dt', '', l)); dl.appendChild(el('dd', '', w)) } } + if (h && nichtLeer(adr(h))) zeile('Hersteller', adr(h)) + else zeile('Verkäufer und Verantwortlicher', anbieterZeile()) + var eu = istObjekt(si.euVerantwortlicher) ? adr(si.euVerantwortlicher) : '' + zeile('Verantwortliche Person in der EU', eu) + zeile('Sicherheitshinweise', si.hinweise) + } + + function zeichneDetail () { + var p = produktMitId(S.produktId) + if (!p) { gehe('raster'); return } + var bilder = liste(p.bilder).filter(function (b) { return istObjekt(b) && absUrl(api, b.gross) }) + var mitVar = hatVarianten(p) + var verkaeuflich = p.ausverkauft !== true && p.verfuegbar !== false + + add(knopf('wks-btn wks-btn-sek', 'Zurück zur Übersicht', function () { gehe('raster') })) + add(titel(p.name)) + meldungAnzeigen() + + var gitter = el('div', 'wks-detail') + var links = el('div', 'wks-galerie') + if (bilder.length) { + var gross = el('img', 'wks-galerie-gross') + gross.alt = p.name + gross.decoding = 'async' + gross.src = absUrl(api, bilder[Math.min(S.bild, bilder.length - 1)].gross) + links.appendChild(gross) + if (bilder.length > 1) { + var daumen = el('div', 'wks-daumen') + bilder.forEach(function (b, i) { + var db = el('button') + db.type = 'button' + db.setAttribute('aria-label', 'Bild ' + (i + 1) + ' von ' + bilder.length + ' anzeigen') + db.setAttribute('aria-pressed', String(i === S.bild)) + var di = el('img') + di.src = absUrl(api, b.klein) || absUrl(api, b.gross) + di.alt = '' + di.loading = 'lazy' + db.appendChild(di) + db.addEventListener('click', function () { + S.bild = i + gross.src = absUrl(api, b.gross) + knoten(daumen.childNodes).forEach(function (c, j) { c.setAttribute('aria-pressed', String(j === i)) }) + }) + daumen.appendChild(db) + }) + links.appendChild(daumen) + } + } + gitter.appendChild(links) + + var rechts = el('div', 'wks-info') + var preisEl = el('p', 'wks-preis') + var gpEl = el('p', 'wks-grundpreis') + var verfEl = el('p', verkaeuflich ? 'wks-klein' : 'wks-aus') + rechts.appendChild(preisEl) + rechts.appendChild(gpEl) + var hinweis = el('p', 'wks-hinweiszeile') + var dvl = versandLink() + if (nichtLeer(shop().steuerHinweisKurz)) hinweis.appendChild(document.createTextNode(shop().steuerHinweisKurz + (dvl ? ' ' : ''))) + if (dvl) hinweis.appendChild(dvl) + if (hinweis.childNodes.length) rechts.appendChild(hinweis) + verfEl.textContent = verkaeuflich ? 'Verfügbar' : 'Ausverkauft' + rechts.appendChild(verfEl) + + var form = el('form', 'wks-form') + form.noValidate = true + var varSel = null + var fehlerVar = el('p', 'wks-feldfehler') + fehlerVar.hidden = true + fehlerVar.id = fid('var-e') + function aktualisierePreis () { + var w = mitVar ? varianteMitId(p, S.auswahl.v) : null + preisEl.textContent = w && nichtLeer(w.preisText) ? w.preisText : preisAnzeige(p) + var gp = w && nichtLeer(w.grundpreisText) ? w.grundpreisText : (w ? '' : p.grundpreisText) + gpEl.textContent = nichtLeer(gp) ? gp : '' + gpEl.hidden = !nichtLeer(gp) + if (w) { + verfEl.textContent = w.verfuegbar === false ? 'Ausverkauft' : 'Verfügbar' + verfEl.className = w.verfuegbar === false ? 'wks-aus' : 'wks-klein' + } + } + if (mitVar) { + var vf = el('div', 'wks-feld') + var vl = el('label', 'wks-label', p.varianten.name) + varSel = document.createElement('select') + varSel.id = fid('variante') + varSel.className = 'wks-eingabe' + vl.htmlFor = varSel.id + var o0 = document.createElement('option') + o0.value = '' + o0.textContent = 'Bitte wählen' + varSel.appendChild(o0) + liste(p.varianten.werte).forEach(function (w) { + var o = document.createElement('option') + o.value = w.id + o.textContent = w.name + (nichtLeer(w.preisText) ? ' – ' + w.preisText : '') + (w.verfuegbar === false ? ' (ausverkauft)' : '') + if (w.verfuegbar === false) o.disabled = true + varSel.appendChild(o) + }) + varSel.value = S.auswahl.v + varSel.addEventListener('change', function () { + S.auswahl.v = varSel.value + fehlerVar.hidden = true + varSel.removeAttribute('aria-invalid') + aktualisierePreis() + }) + vf.appendChild(vl) + vf.appendChild(varSel) + vf.appendChild(fehlerVar) + form.appendChild(vf) + } + aktualisierePreis() + var bestaetigung = el('p', 'wks-meldung') + bestaetigung.hidden = true + if (verkaeuflich) { + var mf = el('div', 'wks-feld') + var mw = mengeFeld(S.auswahl.n, function (n) { S.auswahl.n = n }, 'Menge', 'menge') + mf.appendChild(mw) + form.appendChild(mf) + var plus = el('button', 'wks-btn', 'In den Warenkorb') + plus.type = 'submit' + form.appendChild(plus) + form.addEventListener('submit', function (e) { + e.preventDefault() + if (mitVar && !S.auswahl.v) { + fehlerVar.textContent = 'Bitte wählen Sie eine Variante (' + p.varianten.name + ').' + fehlerVar.hidden = false + varSel.setAttribute('aria-invalid', 'true') + varSel.setAttribute('aria-describedby', fehlerVar.id) + varSel.focus() + ansage(fehlerVar.textContent) + return + } + var n = parseInt(form.querySelector('input[type="number"]').value, 10) + if (!(n >= 1)) n = 1 + legeInKorb(p.id, mitVar ? S.auswahl.v : '', n) + bestaetigung.textContent = p.name + ' wurde in den Warenkorb gelegt.' + bestaetigung.hidden = false + ansage(bestaetigung.textContent) + zeichneKopf() + }) + } + rechts.appendChild(form) + rechts.appendChild(bestaetigung) + var weiter = el('div', 'wks-nav') + weiter.appendChild(knopf('wks-btn wks-btn-sek', 'Weiter einkaufen', function () { gehe('raster') })) + weiter.appendChild(knopf('wks-btn wks-btn-sek', 'Zum Warenkorb', function () { gehe('korb') })) + rechts.appendChild(weiter) + gitter.appendChild(rechts) + add(gitter) + + if (nichtLeer(p.beschreibung)) { + add(el('h4', 'wks-untertitel', 'Beschreibung')) + add(absaetze(p.beschreibung, 'wks-beschreibung')) + } + var fakten = el('dl', 'wks-zusammen') + function fakt (l, w) { if (nichtLeer(w)) { fakten.appendChild(el('dt', '', l)); fakten.appendChild(el('dd', '', w)) } } + fakt('Lieferzeit', shop().lieferzeit) + fakt('Lieferart', p.lieferHinweis) + fakt('Widerrufsrecht', p.widerrufText) + if (fakten.childNodes.length) add(fakten) + + if (istObjekt(p.lebensmittel) && nichtLeer(p.lebensmittel.pflichtangaben)) { + add(el('h4', 'wks-untertitel', 'Angaben nach Lebensmittelrecht')) + add(absaetze(p.lebensmittel.pflichtangaben, 'wks-text')) + } + add(el('h4', 'wks-untertitel', 'Produktsicherheit')) + var sicher = el('dl', 'wks-zusammen') + herstellerZeilen(sicher, p) + add(sicher) + } + + function legeInKorb (pid, vid, n) { + var gefunden = null + S.korb.forEach(function (z) { if (z.id === pid && z.v === vid) gefunden = z }) + if (gefunden) gefunden.n = Math.min(MAX_MENGE, gefunden.n + n) + else if (S.korb.length < MAX_POSITIONEN) S.korb.push({ id: pid, v: vid, n: Math.min(MAX_MENGE, n) }) + speichereKorb() + S.pruef = null + } + + // ------------------------------------------------------------ Warenkorb + + function zeichneKorb () { + add(titel('Warenkorb')) + meldungAnzeigen() + if (S.korbHinweis) { + S.korbHinweis = false + add(el('p', 'wks-meldung', 'Einige Artikel in Ihrem Warenkorb sind nicht mehr verfügbar und wurden entfernt.')) + } + if (S.korb.length === 0) { + add(el('p', '', 'Ihr Warenkorb ist leer.')) + var nav0 = el('div', 'wks-nav') + nav0.appendChild(knopf('wks-btn', 'Weiter einkaufen', function () { gehe('raster') })) + add(nav0) + return + } + var ul = el('ul', 'wks-zeilen') + S.korb.forEach(function (z, i) { + var p = produktMitId(z.id) + var w = hatVarianten(p) ? varianteMitId(p, z.v) : null + var li = el('li') + var info = el('div', 'wks-zeile-info') + info.appendChild(el('p', 'wks-zeile-name', p.name)) + if (w) info.appendChild(el('p', 'wks-klein', p.varianten.name + ': ' + w.name)) + var einzel = w && nichtLeer(w.preisText) ? w.preisText : p.preisText + info.appendChild(el('p', 'wks-klein', 'Einzelpreis: ' + einzel)) + var gp = w ? w.grundpreisText : p.grundpreisText + if (nichtLeer(gp)) info.appendChild(el('p', 'wks-grundpreis', gp)) + var ausv = w ? w.verfuegbar === false : (p.ausverkauft === true || p.verfuegbar === false) + if (ausv) info.appendChild(el('p', 'wks-aus', 'Ausverkauft')) + li.appendChild(info) + li.appendChild(mengeFeld(z.n, function (n) { z.n = n; speichereKorb(); S.pruef = null; zeichneKopf() }, 'Menge', 'korb-menge-' + i)) + var weg = knopf('wks-btn wks-btn-sek', 'Entfernen', function () { + S.korb.splice(i, 1) + speichereKorb() + S.pruef = null + S.meldung = p.name + ' wurde aus dem Warenkorb entfernt.' + zeichne(false) + if (ui.titel) { S.fokusGesetzt = true; ui.titel.focus() } + }) + weg.setAttribute('aria-label', p.name + ' aus dem Warenkorb entfernen') + li.appendChild(weg) + ul.appendChild(li) + }) + add(ul) + add(el('p', 'wks-hinweiszeile', 'Die Preise, Versandkosten und die Gesamtsumme sehen Sie im nächsten Schritt.')) + var nav = el('div', 'wks-nav') + nav.appendChild(knopf('wks-btn wks-btn-sek', 'Weiter einkaufen', function () { gehe('raster') })) + nav.appendChild(knopf('wks-btn', 'Zur Kasse', function () { gehe('kasse') })) + add(nav) + } + + // ------------------------------------------------------------ Kasse + + function kannVersenden () { + var s = shop() + if (liste(s.lieferlaender).length === 0) return false + return !S.korb.some(function (z) { var p = produktMitId(z.id); return p && p.lieferart === 'abholung' }) + } + function kannAbholen () { return istObjekt(shop().abholung) && shop().abholung.aktiv === true } + + function positionenBody () { + return S.korb.map(function (z) { + var o = { produktId: z.id, menge: z.n } + if (z.v) o.varianteId = z.v + return o + }) + } + + function landCode () { + var l = liste(shop().lieferlaender) + if (S.land) return S.land + return l.length && istObjekt(l[0]) ? l[0].code : '' + } + + function zeichneKasse () { + if (S.korb.length === 0) { gehe('korb'); return } + var s = shop() + var versand = kannVersenden() + var abholung = kannAbholen() + if (!versand && !abholung) { + add(titel('Bestellung')) + add(el('p', 'wks-fehlertext', 'Mit diesem Warenkorb ist keine Bestellung möglich. Bitte ändern Sie Ihren Warenkorb.')) + add(knopf('wks-btn', 'Zum Warenkorb', function () { gehe('korb') })) + return + } + if (S.lieferart !== 'versand' && S.lieferart !== 'abholung') S.lieferart = versand ? 'versand' : 'abholung' + if (S.lieferart === 'versand' && !versand) S.lieferart = 'abholung' + if (S.lieferart === 'abholung' && !abholung) S.lieferart = 'versand' + if (!S.land) S.land = landCode() + + add(titel('Bestellung prüfen')) + meldungAnzeigen() + var form = el('form', 'wks-form') + form.noValidate = true + + var fs = el('fieldset', 'wks-feld') + fs.appendChild(el('legend', '', 'Lieferart')) + var landWrap = el('div', 'wks-feld') + var landSel = document.createElement('select') + landSel.id = fid('land') + landSel.className = 'wks-eingabe' + var landLab = el('label', 'wks-label', 'Lieferland') + landLab.htmlFor = landSel.id + liste(s.lieferlaender).forEach(function (l) { + if (!istObjekt(l)) return + var o = document.createElement('option') + o.value = l.code + o.textContent = l.name + landSel.appendChild(o) + }) + landSel.value = S.land + landWrap.appendChild(landLab) + landWrap.appendChild(landSel) + function landSichtbar () { landWrap.hidden = S.lieferart !== 'versand' } + function radio (wert, text) { + var r = el('div', 'wks-radio') + var inp = document.createElement('input') + inp.type = 'radio' + inp.name = fid('lieferart') + inp.id = fid('la-' + wert) + inp.value = wert + inp.checked = S.lieferart === wert + inp.addEventListener('change', function () { + if (!inp.checked) return + S.lieferart = wert + landSichtbar() + pruefe() + }) + var lab = el('label', '', text) + lab.htmlFor = inp.id + r.appendChild(inp) + r.appendChild(lab) + return r + } + if (versand) fs.appendChild(radio('versand', 'Versand')) + if (abholung) { + var abText = 'Abholung' + if (nichtLeer(s.abholung.adresse)) abText += ' (' + s.abholung.adresse + ')' + fs.appendChild(radio('abholung', abText)) + if (nichtLeer(s.abholung.hinweis)) fs.appendChild(el('p', 'wks-hinweis', s.abholung.hinweis)) + } + form.appendChild(fs) + form.appendChild(landWrap) + landSichtbar() + landSel.addEventListener('change', function () { S.land = landSel.value; pruefe() }) + + // Gewaehrleistungshinweis: hervorgehoben vor der Zusammenfassung (wird nicht mit der Zusammenfassung neu aufgebaut) + var gkasten = gewaehrKasten() + if (gkasten) form.appendChild(gkasten) + + // Pflichtinformationen: stehen unmittelbar ueber dem Knopf und kommen zum grossen Teil vom Server (/pruefen) + var pflicht = el('div', 'wks-pflicht') + form.appendChild(pflicht) + + form.appendChild(honigfeld()) + var fehlerBox = el('div', 'wks-fehlerbox') + form.appendChild(fehlerBox) + // Einbeziehung der AGB (§ 305 Abs. 2 BGB): ausdruecklicher Hinweis unmittelbar ueber dem Knopf, beide Texte oeffnen sich im Dialog + var agbHinweis = el('p', 'wks-agb-hinweis') + agbHinweis.appendChild(document.createTextNode('Es gelten unsere ')) + agbHinweis.appendChild(knopf('wks-link', 'AGB', function (e) { zeigeAgb(e.currentTarget) })) + agbHinweis.appendChild(document.createTextNode('. Informationen zu Ihrem Widerrufsrecht finden Sie in der ')) + agbHinweis.appendChild(knopf('wks-link', 'Widerrufsbelehrung', function (e) { zeigeBelehrung(e.currentTarget) })) + agbHinweis.appendChild(document.createTextNode('.')) + form.appendChild(agbHinweis) + var senden = el('button', 'wks-btn', 'Zahlungspflichtig bestellen') + senden.type = 'submit' + senden.disabled = true + var nav = el('div', 'wks-nav') + nav.appendChild(knopf('wks-btn wks-btn-sek', 'Zurück zum Warenkorb', function () { gehe('korb') })) + nav.appendChild(senden) + form.appendChild(nav) + add(form) + + function fuellePflicht () { + pflicht.textContent = '' + var ps = S.pruef + if (S.pruefFehler) { + pflicht.appendChild(el('p', 'wks-fehlertext', S.pruefFehler.text)) + if (S.pruefFehler.zumWarenkorb) pflicht.appendChild(knopf('wks-btn wks-btn-sek', 'Warenkorb ändern', function () { gehe('korb') })) + if (S.pruefFehler.erneut) pflicht.appendChild(knopf('wks-btn wks-btn-sek', 'Erneut versuchen', function () { pruefe() })) + senden.disabled = true + return + } + if (!ps) { + pflicht.appendChild(el('p', 'wks-laden', 'Wird berechnet …')) + senden.disabled = true + return + } + pflicht.appendChild(el('h4', '', 'Ihre Bestellung')) + var ul = el('ul', 'wks-pos') + liste(ps.positionen).forEach(function (z) { + var li = el('li') + var t = el('span', 'wks-pos-text', z.menge + ' × ' + z.name + (nichtLeer(z.varianteText) ? ' (' + z.varianteText + ')' : '') + ', je ' + z.einzelText) + li.appendChild(t) + li.appendChild(el('span', '', z.summeText)) + ul.appendChild(li) + }) + pflicht.appendChild(ul) + var t = ps.texte + var dl = el('dl', 'wks-summen') + dl.appendChild(el('dt', '', 'Zwischensumme')) + dl.appendChild(el('dd', '', t.zwischensumme)) + dl.appendChild(el('dt', '', S.lieferart === 'abholung' ? 'Lieferart' : 'Versandkosten')) + dl.appendChild(el('dd', '', t.versand)) + dl.appendChild(el('dt', 'wks-gesamt', 'Gesamtsumme')) + dl.appendChild(el('dd', 'wks-gesamt', t.gesamt)) + pflicht.appendChild(dl) + if (nichtLeer(t.steuerHinweis)) pflicht.appendChild(el('p', 'wks-klein', t.steuerHinweis)) + else if (nichtLeer(s.steuerHinweisKurz)) pflicht.appendChild(el('p', 'wks-klein', s.steuerHinweisKurz)) + if (nichtLeer(s.mindestbestellText)) pflicht.appendChild(el('p', 'wks-klein', s.mindestbestellText)) + + pflicht.appendChild(el('h4', '', 'Lieferung und Zahlung')) + var lz = el('dl', 'wks-zusammen') + function zeile (l, w) { if (nichtLeer(w)) { lz.appendChild(el('dt', '', l)); lz.appendChild(el('dd', '', w)) } } + zeile('Lieferart', S.lieferart === 'abholung' ? 'Abholung' : 'Versand' + (S.land ? ' nach ' + landName(S.land) : '')) + zeile('Lieferzeit', s.lieferzeit) + zeile('Zahlungsarten', s.zahlungsarten) + zeile('Anbieter', anbieterZeile()) + pflicht.appendChild(lz) + + pflicht.appendChild(el('h4', '', 'Widerrufsrecht')) + pflicht.appendChild(el('p', '', 'Sie haben ein gesetzliches Widerrufsrecht, soweit unten für einzelne Artikel nichts anderes steht. Einzelheiten finden Sie in der Widerrufsbelehrung.')) + liste(ps.positionen).forEach(function (z) { + var txt = nichtLeer(z.widerrufText) ? z.widerrufText : ((produktMitId(z.produktId) || {}).widerrufText) + if (nichtLeer(txt)) pflicht.appendChild(el('p', '', z.name + ': ' + txt)) + }) + var links = el('p', 'wks-links') + links.appendChild(knopf('wks-link', 'AGB lesen', function (e) { zeigeAgb(e.currentTarget) })) + links.appendChild(knopf('wks-link', 'Widerrufsbelehrung lesen', function (e) { zeigeBelehrung(e.currentTarget) })) + links.appendChild(knopf('wks-link', 'Versand & Zahlung lesen', function (e) { zeigeVersand(e.currentTarget) })) + pflicht.appendChild(links) + + pflicht.appendChild(el('p', '', 'Name, Lieferadresse und Zahlung geben Sie im nächsten Schritt bei unserem Zahlungsdienstleister Stripe ein.')) + senden.disabled = S.sendet + } + + function landName (code) { + var l = liste(shop().lieferlaender) + for (var i = 0; i < l.length; i++) if (istObjekt(l[i]) && l[i].code === code) return l[i].name + return code + } + + // Zusammenfassung vom Server (nie im Browser gerechnet); bei jeder Aenderung neu, veraltete Antworten werden verworfen + function pruefe () { + var seq = ++S.pruefSeq + var ansichtSeq = S.seq + S.pruef = null + S.pruefFehler = null + fuellePflicht() + request('POST', '/pruefen', { positionen: positionenBody(), lieferart: S.lieferart, land: landCode() }).then(function (d) { + if (seq !== S.pruefSeq || ansichtSeq !== S.seq) return + if (!d || d.ok !== true || !istObjekt(d.texte) || !Array.isArray(d.positionen)) throw { netz: true } + S.pruef = d + fuellePflicht() + ansage('Die Zusammenfassung wurde aktualisiert. Gesamtsumme ' + d.texte.gesamt + '.') + }).catch(function (err) { + if (seq !== S.pruefSeq || ansichtSeq !== S.seq) return + if (err && err.inaktiv) { zeigeAusweich(); return } + if (err && err.status === 429) S.pruefFehler = { text: meldung429(err), erneut: true } + else if (err && !err.netz && err.fehler && Object.keys(err.fehler).length) { + S.pruefFehler = { text: Object.keys(err.fehler).map(function (k) { return err.fehler[k] }).join(' '), zumWarenkorb: true } + } else S.pruefFehler = { text: 'Gerade nicht erreichbar. Bitte versuchen Sie es später noch einmal.', erneut: true } + fuellePflicht() + ansage(S.pruefFehler.text) + }) + } + S.pruefSeq++ + pruefe() + + form.addEventListener('submit', function (e) { + e.preventDefault() + if (S.sendet || !S.pruef || S.pruefFehler) return + fehlerBox.textContent = '' + bestelle(senden, fehlerBox) + }) + } + + // Verbindlich bestellen: der Server rechnet alles neu und legt die Bestellung an; die Antwort fuehrt zur Zahlung bei Stripe + function bestelle (senden, fehlerBox) { + S.sendet = true + senden.disabled = true + senden.setAttribute('aria-busy', 'true') + sichereConfig().then(function (neu) { + if (neu) { + S.sendet = false + gehe('kasse', { meldung: 'Der Shop wurde aktualisiert. Bitte prüfen Sie Ihre Bestellung noch einmal.' }) + return null + } + return warteAufFormToken(senden).then(function (los) { + if (!los) return null + return request('POST', '/bestellung', { + positionen: positionenBody(), + lieferart: S.lieferart, + land: landCode(), + rueckUrl: location.href, + formToken: S.cfg.formToken, + kontakt_url: S.honig + }).then(function (d) { + var ziel = d ? httpsUrl(d.url) : '' + if (!ziel) throw { netz: true } + senden.textContent = 'Weiterleitung zu Stripe …' + ansage('Sie werden zur Zahlung bei Stripe weitergeleitet.') + location.href = ziel // S.sendet bleibt gesetzt: kein zweiter Klick waehrend der Weiterleitung + }) + }) + }).catch(function (err) { + S.sendet = false + senden.removeAttribute('aria-busy') + senden.disabled = false + if (err && err.inaktiv) return + nachSendeFehler(err, senden, fehlerBox) + }) + } + + function nachSendeFehler (err, senden, fehlerBox) { + if (err && err.code === 'neu_laden') { + // Formular-Token abgelaufen oder ungueltig: Konfiguration neu holen, dem Besucher Bescheid geben + lade(true).then(function () { + bereinigeKorb() + gehe('kasse', { fokus: false, meldung: 'Das Formular wurde erneuert. Bitte versuchen Sie es noch einmal.' }) + }, function (e2) { if (e2 && e2.inaktiv) zeigeAusweich(); else ausfall(function () { zeichne(true) }) }) + return + } + if (err && err.status === 429) { err.fehler = { _: meldung429(err) } } + if (err && !err.netz && err.fehler && Object.keys(err.fehler).length) { + fehlerBox.textContent = '' + zeigeRest(fehlerBox, Object.keys(err.fehler).map(function (k) { return err.fehler[k] })) + return + } + ausfall(function () { zeichne(true) }) + } + + // ------------------------------------------------------------ Rueckkehr von Stripe + + function zeigeRueckkehr (r) { + if (r.art === 'abbruch') { + S.ansicht = bestellbar() ? 'korb' : startAnsicht() + S.meldung = 'Die Zahlung wurde abgebrochen. Ihre Bestellung wurde nicht abgeschlossen.' + (S.korb.length ? ' Ihr Warenkorb ist noch gefüllt.' : '') + zeichne(true) + return + } + S.ansicht = 'status' + S.status = { id: r.id, t: r.t, daten: null, fehler: false, versuch: 0 } + zeichne(true) + holeStatus(S.statusSeq) + } + + function holeStatus (seq) { + var st = S.status + st.versuch++ + request('GET', '/bestellung/' + encodeURIComponent(st.id) + '?t=' + encodeURIComponent(st.t)).then(function (d) { + if (seq !== S.statusSeq) return + if (!d || !istText(d.status)) throw { netz: true } + st.daten = d + st.fehler = false + if (d.status !== 'offen' && d.status !== 'storniert') { + S.korb = [] + speichereKorb() + zeichneKopf() + } + fuelleStatus() + if (d.status === 'offen' && st.versuch < STATUS_VERSUCHE) { + setTimeout(function () { if (seq === S.statusSeq) holeStatus(seq) }, STATUS_ABSTAND_MS) + } + }).catch(function (err) { + if (seq !== S.statusSeq) return + if (err && err.inaktiv) return + if (!st.daten) st.fehler = true + fuelleStatus() + }) + } + + function zeichneStatus () { + add(titel('Ihre Bestellung')) + var box = el('div', 'wks-statusbox') + box.id = fid('status') + add(box) + ui.statusBox = box + add(knopf('wks-btn wks-btn-sek', 'Zurück zum Shop', function () { gehe(startAnsicht()) })) + fuelleStatus() + } + + function fuelleStatus () { + var box = ui.statusBox + var st = S.status + if (!box || !st || S.ansicht !== 'status') return + box.textContent = '' + var d = st.daten + var text = '' + if (!d) { + if (st.fehler) { + box.appendChild(el('p', 'wks-fehlertext', 'Wir können den Stand Ihrer Bestellung gerade nicht abrufen. Wenn Sie bezahlt haben, erhalten Sie eine Bestätigung per E-Mail.')) + text = box.textContent + } else { + box.appendChild(el('p', 'wks-laden', 'Ihre Bestellung wird geprüft …')) + text = 'Ihre Bestellung wird geprüft.' + } + ansage(text) + return + } + var nr = nichtLeer(d.bestellnummer) ? d.bestellnummer : '' + if (d.status === 'storniert') { + box.appendChild(el('p', 'wks-ergebnis-text', 'Ihre Bestellung' + (nr ? ' ' + nr : '') + ' wurde nicht abgeschlossen. Die Zahlung ist nicht erfolgt oder wurde abgebrochen.')) + box.appendChild(el('p', '', 'Wenn Sie den Artikel noch möchten, legen Sie ihn bitte erneut in den Warenkorb.')) + } else { + box.appendChild(el('p', 'wks-ergebnis-text', 'Vielen Dank für Ihre Bestellung' + (nr ? ' ' + nr : '') + '!')) + if (d.status === 'offen') { + box.appendChild(el('p', '', 'Die Zahlung ist noch nicht bestätigt. Bei einer SEPA-Lastschrift kann das einige Tage dauern. Sobald sie bestätigt ist, erhalten Sie eine E-Mail.')) + } else { + box.appendChild(el('p', '', 'Ihre Zahlung ist eingegangen.' + (nichtLeer(d.email) ? ' Eine Bestätigung senden wir an ' + d.email + '.' : ' Eine Bestätigung senden wir Ihnen per E-Mail.'))) + } + var dl = el('dl', 'wks-zusammen') + if (nr) { dl.appendChild(el('dt', '', 'Bestellnummer')); dl.appendChild(el('dd', '', nr)) } + if (nichtLeer(d.gesamtText)) { dl.appendChild(el('dt', '', 'Gesamtsumme')); dl.appendChild(el('dd', '', d.gesamtText)) } + if (d.lieferart === 'versand' || d.lieferart === 'abholung') { dl.appendChild(el('dt', '', 'Lieferart')); dl.appendChild(el('dd', '', d.lieferart === 'abholung' ? 'Abholung' : 'Versand')) } + if (dl.childNodes.length) box.appendChild(dl) + } + ansage(box.textContent) + } + + // ------------------------------------------------------------ Geschlossen + + function zeichneGeschlossen () { + add(titel('Shop geschlossen')) + add(el('p', 'wks-ergebnis-text', 'Der Shop ist gerade geschlossen.')) + } + + // ------------------------------------------------------------ Widerruf (Widerrufsfunktion, § 356a BGB) + + function zeigeWiderruf () { + S.wdSchritt = 'form' + S.wdEingang = '' + gehe('widerruf') + } + + function eingangText (e) { + if (istText(e)) return e + if (istObjekt(e)) return [e.datum, nichtLeer(e.uhrzeit) ? e.uhrzeit + ' Uhr' : ''].filter(nichtLeer).join(', ') + return '' + } + + function zeichneWiderruf () { + add(titel('Vertrag widerrufen')) + var w = S.wd + if (S.wdSchritt === 'fertig') { + var e = eingangText(S.wdEingang) + add(el('p', 'wks-ergebnis-text', 'Ihr Widerruf ist am ' + (e || 'heute') + ' bei uns eingegangen. Eine Eingangsbestätigung haben wir Ihnen per E-Mail geschickt.')) + ansage('Ihr Widerruf ist bei uns eingegangen.') + if (!nurWiderruf()) { + var nav0 = el('div', 'wks-nav') + nav0.appendChild(knopf('wks-btn wks-btn-sek', 'Zurück zum Shop', zurueckVomWiderruf)) + add(nav0) + } + return + } + if (S.wdSchritt === 'pruefen') { + add(el('p', '', 'Bitte prüfen Sie Ihre Angaben. Mit „Widerruf bestätigen“ erklären Sie den Widerruf Ihres Vertrags.')) + var dl = el('dl', 'wks-zusammen') + function zeile (l, v) { if (nichtLeer(v)) { dl.appendChild(el('dt', '', l)); dl.appendChild(el('dd', '', v)) } } + zeile('Name', w.name.trim()) + zeile('E-Mail-Adresse', w.email.trim()) + zeile('Bestellnummer', w.bestellnummer.trim()) + zeile('Erklärung', w.nachricht.trim()) + add(dl) + var fb = el('div', 'wks-fehlerbox') + add(fb) + var senden = el('button', 'wks-btn', 'Widerruf bestätigen') + senden.type = 'button' + var nav1 = el('div', 'wks-nav') + nav1.appendChild(knopf('wks-btn wks-btn-sek', 'Angaben ändern', function () { S.wdSchritt = 'form'; zeichne(true) })) + nav1.appendChild(senden) + add(nav1) + senden.addEventListener('click', function () { sendeWiderruf(senden, fb) }) + return + } + add(el('p', '', 'Hier können Sie Ihren Vertrag widerrufen. Bitte geben Sie Ihren Namen, die E-Mail-Adresse Ihrer Bestellung und möglichst die Bestellnummer an. Sie steht in Ihrer Bestätigungs-E-Mail.')) + meldungAnzeigen() + var form = el('form', 'wks-form') + form.noValidate = true + var refs = {} + form.appendChild(feld(refs, { name: 'name', label: 'Ihr Name', typ: 'text', wert: w.name, pflicht: true, autocomplete: 'name', maxlength: 100, onInput: function (v) { w.name = v } })) + form.appendChild(feld(refs, { name: 'email', label: 'E-Mail-Adresse der Bestellung', typ: 'email', wert: w.email, pflicht: true, autocomplete: 'email', maxlength: 254, onInput: function (v) { w.email = v } })) + form.appendChild(feld(refs, { name: 'bestellnummer', label: 'Bestellnummer (falls zur Hand)', typ: 'text', wert: w.bestellnummer, pflicht: false, maxlength: 40, hinweis: 'Ohne Bestellnummer: Nennen Sie bitte Bestelldatum und Artikel im Feld „Erklärung“, damit wir Ihren Vertrag zuordnen können.', onInput: function (v) { w.bestellnummer = v } })) + form.appendChild(feld(refs, { name: 'nachricht', label: 'Erklärung', typ: 'textarea', wert: w.nachricht, pflicht: false, maxlength: 1000, hinweis: 'Zum Beispiel, welche Artikel Sie widerrufen. Ohne Angabe widerrufen Sie die gesamte Bestellung.', onInput: function (v) { w.nachricht = v } })) + form.appendChild(honigfeld()) + var fehlerBox = el('div', 'wks-fehlerbox') + form.appendChild(fehlerBox) + var weiter = el('button', 'wks-btn', 'Weiter') + weiter.type = 'submit' + var nav = el('div', 'wks-nav') + if (!nurWiderruf()) nav.appendChild(knopf('wks-btn wks-btn-sek', 'Zurück zum Shop', zurueckVomWiderruf)) + nav.appendChild(weiter) + form.appendChild(nav) + add(form) + if (Object.keys(S.fehler).length) zeigeRest(fehlerBox, zeigeFeldfehler(refs, S.fehler)) + form.addEventListener('submit', function (e) { + e.preventDefault() + var f = {} + if (!w.name.trim()) f.name = 'Bitte geben Sie Ihren Namen an.' + if (!EMAIL_FORM.test(w.email.trim())) f.email = 'Bitte geben Sie eine gültige E-Mail-Adresse an.' + if (!w.bestellnummer.trim() && !w.nachricht.trim()) f.nachricht = 'Bitte nennen Sie Bestelldatum und Artikel, damit wir Ihren Vertrag zuordnen können.' + fehlerBox.textContent = '' + zeigeFeldfehler(refs, f) + if (Object.keys(f).length) return + S.fehler = {} + S.wdSchritt = 'pruefen' + zeichne(true) + }) + } + + function zurueckVomWiderruf () { + S.wdSchritt = 'form' + var z = S.vorWiderruf + gehe(z && z !== 'widerruf' && z !== 'status' && z !== 'kasse' ? z : startAnsicht()) + } + + function sendeWiderruf (senden, fehlerBox) { + if (S.sendet) return + var w = S.wd + S.sendet = true + senden.disabled = true + senden.setAttribute('aria-busy', 'true') + sichereConfig().then(function () { + return warteAufFormToken(senden) + }).then(function (los) { + if (!los) return null + return request('POST', '/widerruf', { + name: w.name.trim(), email: w.email.trim(), bestellnummer: w.bestellnummer.trim(), nachricht: w.nachricht.trim(), + formToken: S.cfg.formToken, kontakt_url: S.honig + }).then(function (d) { + S.sendet = false + if (d && d.aktiv === false) { zeigeAusweich(); return } + if (!d || d.ok !== true) throw { netz: true } + S.wdEingang = d.eingang + S.wdSchritt = 'fertig' + S.wd = { name: '', email: '', bestellnummer: '', nachricht: '' } + zeichne(true) + }) + }).catch(function (err) { + S.sendet = false + senden.removeAttribute('aria-busy') + senden.disabled = false + if (err && err.inaktiv) return + if (err && err.code === 'neu_laden') { + lade(true).then(function () { S.wdSchritt = 'form'; gehe('widerruf', { fokus: false, meldung: 'Das Formular wurde erneuert. Bitte senden Sie es noch einmal ab.' }) }, function () { ausfall(function () { zeichne(true) }) }) + return + } + if (err && err.status === 429) err.fehler = { _: meldung429(err) } + if (err && !err.netz && err.fehler && Object.keys(err.fehler).length) { + var felder = ['name', 'email', 'bestellnummer', 'nachricht'] + if (Object.keys(err.fehler).some(function (k) { return felder.indexOf(k) >= 0 })) { + S.wdSchritt = 'form' + gehe('widerruf', { fehler: err.fehler }) + return + } + fehlerBox.textContent = '' + zeigeRest(fehlerBox, Object.keys(err.fehler).map(function (k) { return err.fehler[k] })) + return + } + ausfall(function () { zeichne(true) }) + }) + } + + // ------------------------------------------------------------ Start + + function starte () { + var cfg = S.cfg + var at = S.cfgAt + S = neuerZustand() + S.cfg = cfg + S.cfgAt = at + setzeAkzent(ui.box, shop().akzent) + if (!nurWiderruf()) { + S.korb = korbLesen(key) + bereinigeKorb() + } + var r = rueckkehr + if (r) { + rueckkehr = null + bereinigeUrl(['wk_bestellung', 'wk_t', 'wk_abbruch']) + if (!nurWiderruf()) { zeigeRueckkehr(r); return } + } + var wunsch = root.getAttribute('data-ansicht') || queryAnsicht + queryAnsicht = '' + if (wunsch === 'widerruf') { gehe('widerruf', { fokus: false }); return } + gehe(startAnsicht(), { fokus: false }) + if (wunsch === 'agb' && rechtstexte() && nichtLeer(rechtstexte().agb)) zeigeAgb(null) + } + + // Zurueck per Browser-Verlauf von Stripe (Seite aus dem Zwischenspeicher): der Knopf "Zahlungspflichtig bestellen" muss wieder gehen + window.addEventListener('pageshow', function (e) { + if (e.persisted && S && S.sendet && S.cfg && !S.tot) { + S.sendet = false + zeichne(false) + } + }) + + // Der Ausweichtext der Seite wird beiseite gelegt, sobald der Shop aktiv ist; scheitert der Aufbau, steht er wieder da + lade().then(function () { + injectStyle() + ausweich = document.createDocumentFragment() + while (root.firstChild) ausweich.appendChild(root.firstChild) + try { + bauGeruest(root) + starte() + } catch (e) { + zeigeAusweich() + } + }, function () { /* nicht erreichbar oder nicht aktiv: der Ausweichtext der Seite bleibt unberuehrt stehen */ }) + } + + function run () { + var b = queryWert('wk_bestellung') + var t = queryWert('wk_t') + if (ID_FORM.test(b) && TOKEN_FORM.test(t)) rueckkehr = { art: 'status', id: b, t: t } + else if (queryWert('wk_abbruch')) rueckkehr = { art: 'abbruch' } + var a = queryWert('ansicht') + if (a === 'widerruf' || a === 'agb') queryAnsicht = a + var nodes = document.querySelectorAll('[data-webklar-shop]') + for (var i = 0; i < nodes.length; i++) init(nodes[i]) + } + if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', run) + else run() +})() diff --git a/public/fn-util.js b/public/fn-util.js new file mode 100644 index 0000000..5b1ef11 --- /dev/null +++ b/public/fn-util.js @@ -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, ''') +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 +} diff --git a/public/funktion-bestellen.js b/public/funktion-bestellen.js index 8dfcad5..364ff1e 100644 --- a/public/funktion-bestellen.js +++ b/public/funktion-bestellen.js @@ -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//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 } diff --git a/public/funktionen.css b/public/funktionen.css index 0cbfd4b..d3078ec 100644 --- a/public/funktionen.css +++ b/public/funktionen.css @@ -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%; } -} diff --git a/public/funktionen.js b/public/funktionen.js index 408a3e4..d1cf11f 100644 --- a/public/funktionen.js +++ b/public/funktionen.js @@ -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', ''], ] -const esc = (value) => String(value ?? '') - .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: '', calendar: '', chart: '', + shop: '', clock: '', } const svg = (name) => `` @@ -121,15 +101,9 @@ function newsTile(projects, price = PRICE) { const ZU_VERTRAEGE_LINK = `` /* 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) => `` + // 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 = '

      Sobald deine Website angelegt ist, kannst du den Webshop anfragen.

      ' + else if (state === 'disabled') extra = '

      Der Webshop ist für diese Website abgeschaltet. Deine Bestellungen kannst du noch ansehen.

      ' + const count = projects.length > 1 && active ? `

      aktiv für ${active} von ${projects.length} Websites

      ` : '' + return ` +
      +
      ${svg('shop')}${pill[0]}
      +

      Webshop

      +

      Dein eigener Online-Shop auf deiner Website. Zahlungen gehen direkt auf dein Stripe-Konto. ${esc(preis)} einmalig.

      + ${extra} + ${count} + ${action} +
      ` +} + /* 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 = `
      ${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)}
      ` @@ -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 = '
      Die Analyse ist für deine Websites noch nicht freigeschaltet.
      ' - return - } - const project = usable.find((p) => p.projectId === analyseSel.projectId) || pickAnalyseProject(usable) - analyseSel.projectId = project.projectId - root.innerHTML = ` - ${usable.length > 1 ? ` -
      - - -
      ` : ''} -
      ` - root.querySelector('#an-project')?.addEventListener('change', (ev) => { - analyseSel.projectId = ev.target.value - initAnalyse(root).catch((err) => { root.innerHTML = `

      ${esc(err.message)}

      ` }) - }) - const stage = root.querySelector('#an-stage') - const a = analyseOf(project) - const reload = () => initAnalyse(root).catch((err) => { root.innerHTML = `

      ${esc(err.message)}

      ` }) - if (a.state === 'active') renderAnalyseStats(stage, project, reload) - else if (a.state === 'disabled') stage.innerHTML = '

      Analyse ist abgeschaltet

      Für diese Website ist die Analyse im Moment gesperrt. , dann klären wir das.

      ' - else if (a.state === 'unknown') stage.innerHTML = '

      Die Daten konnten gerade nicht geladen werden.

      ' - else if (!a.eligible) stage.innerHTML = `

      Analyse nicht verfügbar

      ${esc(a.reasonText || 'Für diese Website gerade nicht verfügbar.')}${a.reason === 'kein_abo' ? ` ${ZU_VERTRAEGE_LINK}` : ''}

      ` - else renderAnalyseActivation(stage, project, reload) -} - -function bannerPreview(color) { - const c = bannerColor(color) - const btn = `style="background:${c};border-color:${c};color:#fff"` - return ` - ` -} - -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 `` - return `

      Auf der Datenschutz-Zusatzseite Deiner Website erscheinen als Verantwortlicher:

      ${controllerLines(controller).map(esc).join(' · ')}

      Stimmt etwas nicht? Schreib uns im Chat, bevor Du einschaltest.

      ` -} - -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 = ` -
      -

      Analyse für ${esc(project.projectName)} einschalten

      -

      Du siehst, wie oft deine Website aufgerufen wird, woher die Besucher kommen und was sie sich ansehen.

      -

      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.

      -

      Farbe des Banners

      -
      - -
      ${PRESET_COLORS.map((c) => ``).join('')}
      -
      - -
      - ${controllerBlock(controller)} - - -
      -
      ` - 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 '

      Noch keine Daten.

      ' - 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 ` - - Seitenaufrufe pro Tag vom ${esc(first)} bis ${esc(last)}, insgesamt ${esc(nf.format(total))} - ${m.yTicks.map((t) => `${esc(t.label)}`).join('')} - ${m.xLabels.map((l) => `${esc(l.label)}`).join('')} - - - ${m.points.length <= 31 ? m.points.map((p) => `${esc(p.day)}: ${esc(nf.format(p.pv))}`).join('') : ''} - ` -} - -function barList(title, items, labelOf, { html = false } = {}) { - const rows = barRows(items, labelOf) - return ` -
      -

      ${esc(title)}

      - ${rows.length ? `
        ${rows.map((r) => ` -
      • ${html ? r.label : esc(r.label)}${esc(nf.format(r.count))}
      • `).join('')}
      ` : '

      Noch keine Daten.

      '} -
      ` -} - -const COUNTRY_ROW = (key) => `${flagEmoji(key) ? ` ` : ''}${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 = ` -
      ${[7, 30, 90].map((r) => ``).join('')}
      -

      Lade…

      -
      -
      -

      Länderdaten: IP-Geolocation by DB-IP

      ` - 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 = `` - return - } - if (!stage.isConnected || myRequest !== statsRequestId) return - const t = stats.totals || {} - const rate = Math.round((Number(t.consentRate) || 0) * 100) - const kpi = (label, value) => `
      ${label}${esc(value)}
      ` - const b = stats.banner || {} - data.innerHTML = ` -
      ${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} %`)}
      -

      Seitenaufrufe pro Tag

      ${lineChart(stats.series)}
      -
      - ${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]))} -
      -

      ${esc(KPI_NOTE)}

      ` - 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 = `
      ${esc(heatCaption('loading'))}
      ` - 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', ` In neuem Tab öffnen`) - } catch { - /* Text reicht */ - } -} - -function renderAnalyseHeat(box, projectId, pages) { - if (!box) return - if (!pages.length) { - box.innerHTML = '

      Heatmap

      Sobald Besucher zugestimmt haben und Seiten aufgerufen wurden, kannst du hier sehen, wo geklickt und gescrollt wird.

      ' - return - } - if (!heatListening) { - window.addEventListener('message', onHeatMessage) - heatListening = true - } - const big = heatSel.big === 't' ? 't' : 'd' - box.innerHTML = ` -
      -

      Heatmap

      -
      ${['d', 't'].map((vp) => ``).join('')}
      -
      -

      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.

      -
      - -
      -
      -
      -
      -
      ` - 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 = ` -

      Analyse ausschalten

      -

      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.

      - -
      ` - const actions = box.querySelector('#an-off-actions') - const error = box.querySelector('#an-off-error') - box.querySelector('#an-off-ask').addEventListener('click', () => { - actions.innerHTML = 'Wirklich ausschalten? ' - 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 = ` -

      Analyse ausgeschaltet

      -

      Die Website-Analyse ist ausgeschaltet. Ab jetzt werden keine Daten mehr gespeichert.

      -
      ` - 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') - } - }) - }) -} diff --git a/public/img/gewaehrleistung-eu.webp b/public/img/gewaehrleistung-eu.webp new file mode 100644 index 0000000..ded5555 Binary files /dev/null and b/public/img/gewaehrleistung-eu.webp differ diff --git a/public/rechtliches/avv-terminplaner-2026-10-05.html b/public/rechtliches/avv-terminplaner-2026-10-05.html index dd80625..d05ce7c 100644 --- a/public/rechtliches/avv-terminplaner-2026-10-05.html +++ b/public/rechtliches/avv-terminplaner-2026-10-05.html @@ -54,7 +54,7 @@

      (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.

      -

      (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.

      +

      (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.

      (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.

      @@ -118,7 +118,7 @@

      (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.

      -

      (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).

      +

      (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).

      (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.

      @@ -140,7 +140,7 @@

      (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.

      -

      (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.

      +

      (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.

      (7) Entwürfe aus dem Fragebogen, aus denen keine Bestellung geworden ist, löscht WEBklar 90 Tage nach der letzten Änderung.

      @@ -185,7 +185,7 @@ MissbrauchsschutzEin 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. DatenminimierungPflicht 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. TrennungAlle 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. - LöschungTä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. + LöschungTä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. ProtokolleDie 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). DatensicherungTägliche Vollsicherung des Servers durch Hetzner; verfügbar sind die letzten sieben Tagessicherungen (§ 3 Abs. 5 der Abo-Bedingungen). RechenzentrumHetzner Online GmbH, Rechenzentren Falkenstein und Nürnberg (Deutschland). Hetzner ist nach ISO/IEC 27001 zertifiziert; der Geltungsbereich umfasst die Hosting-Dienstleistungen und die Rechenzentren. diff --git a/public/rechtliches/avv-terminplaner-2026-10-05.pdf b/public/rechtliches/avv-terminplaner-2026-10-05.pdf index a2c853d..94ed9a0 100644 Binary files a/public/rechtliches/avv-terminplaner-2026-10-05.pdf and b/public/rechtliches/avv-terminplaner-2026-10-05.pdf differ diff --git a/public/rechtliches/avv-terminplaner-2026-10-05.pdf.sha256 b/public/rechtliches/avv-terminplaner-2026-10-05.pdf.sha256 index 880d406..70019f8 100644 --- a/public/rechtliches/avv-terminplaner-2026-10-05.pdf.sha256 +++ b/public/rechtliches/avv-terminplaner-2026-10-05.pdf.sha256 @@ -1 +1 @@ -93776bde9b84709dbb6b5065cbf6c31c27080744c8096804d5b6508f9e87c14b +3a10d5b588d05ef9f711fee796157b5c2a9170f39c5d7f2f4f015308d318e9ef diff --git a/public/rechtliches/avv-webshop-2026-10-05.html b/public/rechtliches/avv-webshop-2026-10-05.html new file mode 100644 index 0000000..b0fe563 --- /dev/null +++ b/public/rechtliches/avv-webshop-2026-10-05.html @@ -0,0 +1,197 @@ + + + + + + + Auftragsverarbeitungsvertrag Webshop – WEBklar + + + +
      + + +
      WEBKLAR
      +

      Auftragsverarbeitungsvertrag Webshop (Art. 28 DSGVO)

      + +

      Fassung 2026-10-05

      + +

      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“).

      + +

      Vorbemerkung: Abschluss und Verhältnis zu anderen Verträgen

      + +

      (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.

      + +

      (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.

      + +

      (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.

      + +

      (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).

      + +

      1. Gegenstand und Dauer

      + +

      (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.

      + +

      (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.

      + +

      2. Art und Zweck der Verarbeitung

      + +

      (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.

      + +

      (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).

      + +

      (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.

      + +

      3. Datenarten und Betroffene

      + +

      (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. Kartendaten und Kontodaten (IBAN) erhält und speichert WEBklar nicht; die gibt der Käufer nur bei Stripe ein. Eine Telefonnummer des Käufers erhebt und speichert WEBklar nicht: 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.

      + +

      (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.

      + +

      (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).

      + +

      (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 Hashwert der IP-Adresse („IP-Hash“): 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).

      + +

      (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.

      + +

      (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.

      + +

      4. Weisungen

      + +

      (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.

      + +

      (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.

      + +

      (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.

      + +

      5. Vertraulichkeit

      + +

      WEBklar setzt nur Personen ein, die zur Vertraulichkeit verpflichtet sind oder einer gesetzlichen Verschwiegenheitspflicht unterliegen.

      + +

      6. Technische und organisatorische Maßnahmen

      + +

      (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.

      + +

      (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.

      + +

      7. Unterauftragsverarbeiter

      + +

      (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.

      + +

      (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).

      + +

      (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).

      + +

      (4) Stripe ist kein Unterauftragsverarbeiter von WEBklar. 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.

      + +

      8. Unterstützung des Kunden, Betroffenenrechte

      + +

      (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.

      + +

      (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.

      + +

      (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.

      + +

      (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.

      + +

      (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).

      + +

      (6) Die Käufer informiert der Kunde (Art. 13 DSGVO; Nummer 11 Abs. 2).

      + +

      9. Löschung und Rückgabe

      + +

      (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.

      + +

      (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.

      + +

      (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.

      + +

      (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.

      + +

      (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.

      + +

      (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.

      + +

      (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.

      + +

      (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.

      + +

      10. Nachweise und Kontrollen

      + +

      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.

      + +

      11. Pflichten des Kunden

      + +

      (1) Der Kunde ist Verantwortlicher für die Verarbeitung der Daten seiner Käufer im Webshop.

      + +

      (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.

      + +

      (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).

      + +

      (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.

      + +

      (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).

      + +

      12. Haftung, Änderungen, Schlussbestimmungen

      + +

      (1) Gegenüber betroffenen Personen gilt Art. 82 DSGVO. Für die Haftung zwischen Kunde und WEBklar gilt § 13 der Abo-Bedingungen.

      + +

      (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.

      + +

      (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.

      + +

      (4) Im Übrigen gilt § 16 der Abo-Bedingungen.

      + +

      Anlage: Technische und organisatorische Maßnahmen des Webshops (Art. 32 DSGVO)

      + + + + + + + + + + + + + + + + +
      BereichMaßnahmen
      ÜbertragungDas 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.
      Speicher im BrowserEs 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.
      ZugriffskontrolleDas 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.
      MissbrauchsschutzEin 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).
      DatenminimierungKartendaten 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.
      TrennungAlle 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.
      LöschungTä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).
      ProtokolleDie 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).
      DatensicherungTägliche Vollsicherung des Servers durch Hetzner; verfügbar sind die letzten sieben Tagessicherungen (§ 3 Abs. 5 der Abo-Bedingungen).
      RechenzentrumHetzner Online GmbH, Rechenzentren Falkenstein und Nürnberg (Deutschland). Hetzner ist nach ISO/IEC 27001 zertifiziert; der Geltungsbereich umfasst die Hosting-Dienstleistungen und die Rechenzentren.
      OrganisationVerpflichtung aller Beteiligten auf Vertraulichkeit, Ablauf für Datenschutzverletzungen (Nummer 8 Abs. 3), Überprüfung der Maßnahmen mindestens einmal im Jahr.
      +
      + + diff --git a/public/rechtliches/avv-webshop-2026-10-05.pdf b/public/rechtliches/avv-webshop-2026-10-05.pdf new file mode 100644 index 0000000..c00603c Binary files /dev/null and b/public/rechtliches/avv-webshop-2026-10-05.pdf differ diff --git a/public/rechtliches/avv-webshop-2026-10-05.pdf.sha256 b/public/rechtliches/avv-webshop-2026-10-05.pdf.sha256 new file mode 100644 index 0000000..5c5104e --- /dev/null +++ b/public/rechtliches/avv-webshop-2026-10-05.pdf.sha256 @@ -0,0 +1 @@ +a4a03848c62ad2e9e0f9919949a844337fe631508726cd191f66b6c5b33c89b6 diff --git a/public/rechtliches/leistungsbeschreibung-terminplaner-2026-10-05.html b/public/rechtliches/leistungsbeschreibung-terminplaner-2026-10-05.html index 2117d95..8ffb5ce 100644 --- a/public/rechtliches/leistungsbeschreibung-terminplaner-2026-10-05.html +++ b/public/rechtliches/leistungsbeschreibung-terminplaner-2026-10-05.html @@ -136,7 +136,7 @@

      (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.

      -

      (3) WEBklar löscht alle Termindaten und die Einstellungen 30 Tage nach dem Abschalten.

      +

      (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).

      (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).

      diff --git a/public/rechtliches/leistungsbeschreibung-terminplaner-2026-10-05.pdf b/public/rechtliches/leistungsbeschreibung-terminplaner-2026-10-05.pdf index ebacd9c..0a98f33 100644 Binary files a/public/rechtliches/leistungsbeschreibung-terminplaner-2026-10-05.pdf and b/public/rechtliches/leistungsbeschreibung-terminplaner-2026-10-05.pdf differ diff --git a/public/rechtliches/leistungsbeschreibung-terminplaner-2026-10-05.pdf.sha256 b/public/rechtliches/leistungsbeschreibung-terminplaner-2026-10-05.pdf.sha256 index 9ff7cbf..50bb041 100644 --- a/public/rechtliches/leistungsbeschreibung-terminplaner-2026-10-05.pdf.sha256 +++ b/public/rechtliches/leistungsbeschreibung-terminplaner-2026-10-05.pdf.sha256 @@ -1 +1 @@ -68039f8293eec7128135e96060b8e04f7e6301de123e099c180feb86530f39f7 +da26e937921ef71a5d874551fd085ce63feb9b8663e37571f1a901e75b859473 diff --git a/public/rechtliches/leistungsbeschreibung-webshop-2026-10-05.html b/public/rechtliches/leistungsbeschreibung-webshop-2026-10-05.html new file mode 100644 index 0000000..723130e --- /dev/null +++ b/public/rechtliches/leistungsbeschreibung-webshop-2026-10-05.html @@ -0,0 +1,239 @@ + + + + + + + Leistungsbeschreibung Webshop – WEBklar + + + +
      + + +
      WEBKLAR
      +

      Leistungsbeschreibung Webshop

      + +

      Fassung 2026-10-05 · Zusatzbedingungen zum Vertrag über den Betrieb der Website

      + +

      1. Geltung

      + +

      (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.

      + +

      (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).

      + +

      (3) Im Übrigen gelten die Abo-Bedingungen. Bei Widersprüchen geht für den Webshop diese Leistungsbeschreibung vor, in Datenschutzfragen der AVV Webshop.

      + +

      (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.

      + +

      2. Leistung

      + +

      (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.

      + +

      (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.

      + +

      (3) Im Kundenportal verwaltet der Händler:

      + +
        +
      • Artikel mit Beschreibung, Bildern, Preis, Varianten, Bestand und den Pflichtangaben zum Artikel,
      • +
      • Versand (nur nach Deutschland und Österreich) und Abholung mit Versandkosten und Lieferzeiten,
      • +
      • Bestellungen mit Status (zum Beispiel bezahlt, versendet, abholbereit, abgeschlossen), die er als CSV-Datei exportieren kann,
      • +
      • Erstattungen, ganz oder teilweise, über sein Stripe-Konto (Nr. 4),
      • +
      • die Widerrufe, die Käufer über die Widerrufsfunktion erklärt haben,
      • +
      • die Verbindung mit seinem Stripe-Konto, seine Einstellungen und seine Rechtstexte, deren bestätigte Fassungen WEBklar archiviert (Nr. 6 Abs. 5).
      • +
      + +

      (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).

      + +

      (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.

      + +

      (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.

      + +

      (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).

      + +

      3. Ausgeschlossene Waren und Leistungen

      + +

      (1) Über den Webshop dürfen nicht verkauft werden:

      + +
        +
      • alkoholische Getränke,
      • +
      • Tabakwaren, E-Zigaretten und Zubehör,
      • +
      • Arzneimittel und Medizinprodukte,
      • +
      • Waffen, Munition, Feuerwerk und Sprengstoffe,
      • +
      • Waren mit erotischem oder pornografischem Inhalt,
      • +
      • lebende Tiere,
      • +
      • digitale Inhalte und Downloads,
      • +
      • Gutscheine, Geschenkkarten und Guthaben,
      • +
      • Abonnements und andere wiederkehrende Zahlungen,
      • +
      • Waren und Leistungen, die nach der Liste der verbotenen und eingeschränkten Geschäfte von Stripe nicht erlaubt sind, und
      • +
      • Waren, deren Verkauf oder Versand nach deutschem, österreichischem oder europäischem Recht verboten ist.
      • +
      + +

      (2) Der Händler liefert nur nach Deutschland und Österreich. Eine Lieferung in andere Länder bietet der Webshop nicht an.

      + +

      (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.

      + +

      4. Zahlungen über Stripe

      + +

      (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.

      + +

      (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.

      + +

      (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.

      + +

      (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).

      + +

      (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.

      + +

      (6) Die Zahlungsseite selbst (Stripe Checkout) betreibt Stripe. Kartendaten und Kontodaten gibt der Käufer nur dort ein; WEBklar erhält sie nicht.

      + +

      5. Pflichten des Händlers

      + +

      (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.

      + +

      (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.

      + +

      (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.

      + +

      (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.

      + +

      (5) Er pflegt Bestand, Bestellstatus und Versandstatus im Kundenportal. Die Folgen falscher Angaben oder Statusmeldungen, die der Händler setzt, trägt er.

      + +

      (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.

      + +

      (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.

      + +

      (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.

      + +

      (9) Er hält die Vorgaben nach Nr. 3 ein und verändert den Einbau-Code und die gesetzlich vorgeschriebenen Hinweise des Widgets nicht.

      + +

      (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.

      + +

      (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.

      + +

      6. Muster-Rechtstexte, keine Rechtsberatung

      + +

      (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.

      + +

      (2) Die Mustertexte sind Muster ohne Gewähr. 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).

      + +

      (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).

      + +

      (4) Der Händler bleibt für seine Rechtstexte verantwortlich, auch wenn er Mustertexte unverändert übernimmt (Nr. 5 Abs. 2).

      + +

      (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).

      + +

      7. Verfügbarkeit und Störungen

      + +

      (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).

      + +

      (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.

      + +

      (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.

      + +

      (4) Alle Zeiten gelten in der Zeitzone Europe/Berlin.

      + +

      (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.

      + +

      (6) Die gesetzlichen Rechte des Händlers sowie §§ 12 und 13 der Abo-Bedingungen bleiben unberührt.

      + +

      8. Haftung

      + +

      (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.

      + +

      (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.

      + +

      (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).

      + +

      (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).

      + +

      (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.

      + +

      9. Preis, Abrechnung und Widerruf

      + +

      (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).

      + +

      (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.

      + +

      (3) Der Händler bezahlt bei der Bestellung im Kundenportal über Stripe und erhält eine Rechnung.

      + +

      (4) Der Preis steht neben den Leistungen eines Abos: Die Einrichtung wird weder auf ein Stundenkontingent angerechnet noch damit bezahlt.

      + +

      (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.

      + +

      (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.

      + +

      10. Laufzeit und Ende

      + +

      (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.

      + +

      (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.

      + +

      (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).

      + +

      (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.

      + +

      (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).

      + +

      (6) Endet der Vertrag über den Betrieb der Website, bevor WEBklar den Webshop freigeschaltet hat, erstattet WEBklar den Einrichtungspreis vollständig.

      + +

      (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.

      + +

      11. Änderungen und Einstellung

      + +

      (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.

      + +

      (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

      + +
        +
      • 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),
      • +
      • Stripe die Anbindung nach Nr. 4 beendet oder wesentlich ändert, so dass der Webshop in der bisherigen Form nicht weiterbetrieben werden kann, oder
      • +
      • eine Sicherheitslücke sich nicht anders beheben lässt.
      • +
      + +

      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.

      + +

      (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.

      + +

      (4) Eine Einstellung ohne einen Grund nach Absatz 2 ist ausgeschlossen. Gesetzliche Rechte von Verbrauchern bei Änderungen digitaler Produkte (§ 327r BGB) bleiben unberührt.

      + +

      12. Hinweise

      + +

      (1) Keine Rechtsberatung. 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.

      + +

      (2) Barrierefreiheit. 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.

      +
      + + diff --git a/public/rechtliches/leistungsbeschreibung-webshop-2026-10-05.pdf b/public/rechtliches/leistungsbeschreibung-webshop-2026-10-05.pdf new file mode 100644 index 0000000..182f15a Binary files /dev/null and b/public/rechtliches/leistungsbeschreibung-webshop-2026-10-05.pdf differ diff --git a/public/rechtliches/leistungsbeschreibung-webshop-2026-10-05.pdf.sha256 b/public/rechtliches/leistungsbeschreibung-webshop-2026-10-05.pdf.sha256 new file mode 100644 index 0000000..edc3eeb --- /dev/null +++ b/public/rechtliches/leistungsbeschreibung-webshop-2026-10-05.pdf.sha256 @@ -0,0 +1 @@ +081f98f8a0be20d003863c25417e9896ace5e6fbbacaf6ec3c481f32edee1018 diff --git a/public/shop-anfrage.js b/public/shop-anfrage.js new file mode 100644 index 0000000..8d6aea3 --- /dev/null +++ b/public/shop-anfrage.js @@ -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'))) +} diff --git a/public/shop-dom.js b/public/shop-dom.js new file mode 100644 index 0000000..94a5683 --- /dev/null +++ b/public/shop-dom.js @@ -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) +} diff --git a/public/shop-seite.html b/public/shop-seite.html new file mode 100644 index 0000000..203a116 --- /dev/null +++ b/public/shop-seite.html @@ -0,0 +1,23 @@ + + + + + + +Shop + + + +
      Shop
      +
      +

      Der Shop wird geladen …

      + +
      + + + diff --git a/public/shop-tab-artikel.js b/public/shop-tab-artikel.js new file mode 100644 index 0000000..3c468bb --- /dev/null +++ b/public/shop-tab-artikel.js @@ -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/-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() +} diff --git a/public/shop-tab-bestellungen.js b/public/shop-tab-bestellungen.js new file mode 100644 index 0000000..1493627 --- /dev/null +++ b/public/shop-tab-bestellungen.js @@ -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() +} diff --git a/public/shop-tab-einstellungen.js b/public/shop-tab-einstellungen.js new file mode 100644 index 0000000..b6c6002 --- /dev/null +++ b/public/shop-tab-einstellungen.js @@ -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) +} diff --git a/public/shop-tab-rechtliches.js b/public/shop-tab-rechtliches.js new file mode 100644 index 0000000..d1a46f7 --- /dev/null +++ b/public/shop-tab-rechtliches.js @@ -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() +} diff --git a/public/shop-tab-uebersicht.js b/public/shop-tab-uebersicht.js new file mode 100644 index 0000000..7a58df1 --- /dev/null +++ b/public/shop-tab-uebersicht.js @@ -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 +} diff --git a/public/shop.css b/public/shop.css new file mode 100644 index 0000000..b7779e5 --- /dev/null +++ b/public/shop.css @@ -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); } +} diff --git a/public/shop.js b/public/shop.js new file mode 100644 index 0000000..444b5ce --- /dev/null +++ b/public/shop.js @@ -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= (nach der Zahlung, „Danke!“) und #webshop?stripe=zurueck|neu&projekt= (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= | #webshop?stripe=zurueck|neu&projekt=. */ +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 = '' +} diff --git a/public/termine-view.js b/public/termine-view.js index e399750..d2b9875 100644 --- a/public/termine-view.js +++ b/public/termine-view.js @@ -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() diff --git a/public/termine.js b/public/termine.js index 7db98a9..4cb022d 100644 --- a/public/termine.js +++ b/public/termine.js @@ -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). */ diff --git a/public/vertraege-link.js b/public/vertraege-link.js index b258dd0..adda078 100644 --- a/public/vertraege-link.js +++ b/public/vertraege-link.js @@ -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' }) diff --git a/public/vertragsunterlagen/funktionen-leistungsbeschreibung-2026-09-30.html b/public/vertragsunterlagen/funktionen-leistungsbeschreibung-2026-09-30.html new file mode 100644 index 0000000..ba7197b --- /dev/null +++ b/public/vertragsunterlagen/funktionen-leistungsbeschreibung-2026-09-30.html @@ -0,0 +1,78 @@ + + + + + + + Leistungsbeschreibung Funktionen – News-Pinnwand – WEBklar + + + +
      + + +
      WEBKLAR
      +

      Leistungsbeschreibung: Funktionen – News-Pinnwand

      +

      Stand 30.09.2026

      + +
      + Das Wichtigste auf einen Blick + + + + + +
      LeistungEinbau der News-Pinnwand in Ihre Website durch WEBklar; Pflege der Beiträge durch Sie im Kundenportal
      Preis30,00 € einmalig, inklusive Umsatzsteuer, keine laufenden Kosten
      Dauer des Einbausmeist innerhalb von 1–2 Wochen (keine verbindliche Zusage)
      WiderrufVerbraucher können innerhalb von 14 Tagen widerrufen; das Recht kann nach § 356 Abs. 4 BGB erlöschen (Abschnitt 6)
      +
      + +

      1. Leistung

      +

      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.

      +

      Änderungen an der Website, die über den Einbau der Pinnwand hinausgehen, sind nicht Teil dieser Leistung.

      + +

      2. Preis

      +

      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.

      + +

      3. Einbau und Freischaltung

      +

      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.

      + +

      4. Ende der Zusammenarbeit

      +

      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 (Abschnitt 6a, dort Abschnitt 7).

      + +

      5. Ihre Inhalte

      +

      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.

      + +

      6. Widerrufsrecht

      +

      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 project.webklar.com/widerrufen.html.

      +

      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.

      + +

      Anbieter: WEBklar GbR, Am Schwimmbad 10, 67722 Winnweiler. Es gelten ergänzend die Vertragsbedingungen und die Datenschutzerklärung.

      +
      + + diff --git a/public/vertragsunterlagen/funktionen-leistungsbeschreibung.html b/public/vertragsunterlagen/funktionen-leistungsbeschreibung.html index b4245cb..d46b0c4 100644 --- a/public/vertragsunterlagen/funktionen-leistungsbeschreibung.html +++ b/public/vertragsunterlagen/funktionen-leistungsbeschreibung.html @@ -41,6 +41,7 @@
      WEBKLAR

      Leistungsbeschreibung: Funktionen – News-Pinnwand

      Stand 05.10.2026

      +

      Frühere Fassung vom 30.09.2026: Leistungsbeschreibung (Stand 30.09.2026)

      Das Wichtigste auf einen Blick diff --git a/public/widerrufen.html b/public/widerrufen.html index 003f2e9..231320a 100644 --- a/public/widerrufen.html +++ b/public/widerrufen.html @@ -22,6 +22,7 @@ + diff --git a/scripts/build-legal-html.mjs b/scripts/build-legal-html.mjs index 0a250fd..7c5c4ed 100755 --- a/scripts/build-legal-html.mjs +++ b/scripts/build-legal-html.mjs @@ -5,8 +5,10 @@ // // node scripts/build-legal-html.mjs termine schreibt die drei Seiten des Terminplaners // node scripts/build-legal-html.mjs termine --check vergleicht mit den eingecheckten Seiten (Exit 1 bei Abweichung, schreibt nichts) +// node scripts/build-legal-html.mjs shop [--check] dasselbe für Leistungsbeschreibung und AVV des Webshops (die Widerrufsbelehrung der +// Funktionen gehört zum Satz termine und wird von shop nicht angefasst) // -// Fassung = config.termine.avvVersion. Die Ausgabe enthält keine Datumsangabe außer der Fassung und hängt nur vom Markdown und +// Fassung = config.termine.avvVersion (Satz termine) bzw. config.shop.fassung (Satz shop). Die Ausgabe enthält keine Datumsangabe außer der Fassung und hängt nur vom Markdown und // von der Fassung ab (zweimal erzeugen ergibt dieselben Bytes). Unterstützte Teilmenge von Markdown: Überschriften # bis ### // (# nur als erste Zeile), Absätze, Listen mit "- ", **fett**, `code` und Tabellen (Kopfzeile, Trennzeile |---|---|, Zeilen). // Jede andere Syntax (Codeblöcke, Zitate, Bilder, Links, rohes HTML, nummerierte Listen, Einrückungen, Trennlinien) bricht laut ab, @@ -176,9 +178,9 @@ const STYLE = (seitenkopf) => ` :root { color-scheme: light; } } ` -/** Adresse eines Links der Navigation: relativ zur Wurzel ("/…", nicht "//") oder https. */ +/** Adresse eines Links der Navigation: relativ zur Wurzel ("/…", nicht "//") oder https; kein Rückwärtsschrägstrich (Browser lesen ihn wie "/"). */ function pruefeHref(href) { - if (typeof href !== 'string' || !(/^\/(?!\/)/.test(href) || /^https:\/\/[^\s"'<>]+$/.test(href)) || /[\s"'<>]/.test(href)) { + if (typeof href !== 'string' || !(/^\/(?!\/)/.test(href) || /^https:\/\/[^\s"'<>]+$/.test(href)) || /[\s"'<>\\]/.test(href)) { throw new Error(`render: ungültige Adresse in der Navigation: ${String(href).slice(0, 60)}`) } return href @@ -257,50 +259,74 @@ ${STYLE(meta.seitenkopf)} // ---------------------------------------------------------------------------------------------------------- Dokumentsätze -/** Namen je Dokument des Terminplaners (Schlüssel wie in legalPaths: lb, avv, widerruf). */ +/** Namen je Dokument (Schlüssel wie in legalPaths: lb, avv, widerruf): Seitentitel und Kopfzeile des Drucks. */ const TERMINE_NAMEN = { lb: { titel: 'Leistungsbeschreibung Terminplaner', seitenkopf: 'Leistungsbeschreibung Terminplaner' }, avv: { titel: 'Auftragsverarbeitungsvertrag Terminplaner', seitenkopf: 'AVV Terminplaner' }, widerruf: { titel: 'Widerrufsbelehrung und Muster-Widerrufsformular (Funktionen)', seitenkopf: 'Widerrufsbelehrung Funktionen' }, } -const NAV_NAMEN = { lb: 'Leistungsbeschreibung Terminplaner', avv: 'AVV Terminplaner', widerruf: 'Widerrufsbelehrung Funktionen' } +const SHOP_NAMEN = { + lb: { titel: 'Leistungsbeschreibung Webshop', seitenkopf: 'Leistungsbeschreibung Webshop' }, + avv: { titel: 'Auftragsverarbeitungsvertrag Webshop', seitenkopf: 'AVV Webshop' }, +} +const NAV_NAMEN = { + termine: { lb: 'Leistungsbeschreibung Terminplaner', avv: 'AVV Terminplaner', widerruf: 'Widerrufsbelehrung Funktionen' }, + shop: { lb: 'Leistungsbeschreibung Webshop', avv: 'AVV Webshop', widerruf: 'Widerrufsbelehrung Funktionen' }, +} /** - * Die drei Dokumente des Terminplaners in der Fassung: { doc, md, html, meta } mit Pfaden relativ zur Wurzel des Projekts. + * Dokumente eines Satzes in der Fassung: { doc, md, html, meta } mit Pfaden relativ zur Wurzel des Projekts. + * namen = die Dokumente, die dieser Satz erzeugt (Schlüssel von legalPaths); die Navigation verlinkt alle Dokumente des Satzes. * Quelle docs/rechtliches/.md, Ziel public/rechtliches/-.html (Slugs und Adressen aus legalDocs.js). */ -export function termineDokumente(fassung = config.termine.avvVersion) { - const adressen = legalPaths('termine', fassung) +function dokumenteDesSatzes(satz, fassung, namen) { + const adressen = legalPaths(satz, fassung) const nav = [ { href: '/dashboard.html', text: '← Kundenportal' }, { href: '/abo-bedingungen.html', text: 'Abo-Bedingungen' }, - ...Object.keys(adressen).map((doc) => ({ href: adressen[doc], text: NAV_NAMEN[doc] })), + ...Object.keys(adressen).map((doc) => ({ href: adressen[doc], text: NAV_NAMEN[satz][doc] })), { href: '/datenschutz.html', text: 'Datenschutzerklärung' }, { href: 'https://webklar.com/impressum', text: 'Impressum' }, ] - return Object.entries(adressen).map(([doc, adresse]) => ({ + return Object.keys(namen).map((doc) => ({ doc, - md: `docs/rechtliches/${basename(adresse, `-${fassung}.html`)}.md`, - html: `public${adresse}`, - meta: { fassung, ...TERMINE_NAMEN[doc], nav }, + md: `docs/rechtliches/${basename(adressen[doc], `-${fassung}.html`)}.md`, + html: `public${adressen[doc]}`, + meta: { fassung, ...namen[doc], nav }, })) } +/** Die drei Dokumente des Terminplaners (lb, avv, widerruf) in der Fassung config.termine.avvVersion. */ +export function termineDokumente(fassung = config.termine.avvVersion) { + return dokumenteDesSatzes('termine', fassung, TERMINE_NAMEN) +} + +/** + * Die Dokumente des Webshops in der Fassung config.shop.fassung: lb und avv. Die Widerrufsbelehrung der Funktionen (dritte Adresse + * des Satzes shop) ist dieselbe Datei wie die des Terminplaners und wird nur vom Satz termine erzeugt; die Navigation verlinkt sie. + */ +export function shopDokumente(fassung = config.shop.fassung) { + return dokumenteDesSatzes('shop', fassung, SHOP_NAMEN) +} + +/** Sätze der Kommandozeile: Name => Dokumente. */ +const SAETZE = { termine: termineDokumente, shop: shopDokumente } + // ---------------------------------------------------------------------------------------------------------- Aufruf /** - * Kommandozeile: `termine [--check]`. root = Wurzel des Projekts (Tests übergeben ein Verzeichnis mit Kopien). + * Kommandozeile: `termine|shop [--check]`. root = Wurzel des Projekts (Tests übergeben ein Verzeichnis mit Kopien). * => Exit-Code: 0 ok, 1 mindestens eine Seite weicht ab (nur --check), 2 falscher Aufruf */ export function lauf(argv, { root = ROOT, log = console.log, fehler = console.error } = {}) { const [satz, ...optionen] = argv const pruefen = optionen.includes('--check') - if (satz !== 'termine' || optionen.some((o) => o !== '--check')) { - fehler('Aufruf: node scripts/build-legal-html.mjs termine [--check]') + if (!Object.hasOwn(SAETZE, satz ?? '') || optionen.some((o) => o !== '--check')) { + fehler('Aufruf: node scripts/build-legal-html.mjs termine|shop [--check]') return 2 } let abweichungen = 0 - for (const d of termineDokumente()) { + for (const d of SAETZE[satz]()) { const html = render(readFileSync(join(root, d.md), 'utf8'), d.meta) const ziel = join(root, d.html) if (pruefen) { diff --git a/scripts/build-legal-pdfs.sh b/scripts/build-legal-pdfs.sh index 55cb367..640bb6a 100755 --- a/scripts/build-legal-pdfs.sh +++ b/scripts/build-legal-pdfs.sh @@ -1,13 +1,17 @@ #!/usr/bin/env bash # Erzeugt die Druck-/PDF-Fassungen der Vertragsunterlagen aus den HTML-Quellen. # Laeuft auf dem Host (braucht Chrome/Chromium), die PDFs werden eingecheckt. -# Usage: CHROME=/pfad/zu/chrome scripts/build-legal-pdfs.sh [all|analyse|termine|datenschutz] +# Usage: CHROME=/pfad/zu/chrome scripts/build-legal-pdfs.sh [all|analyse|termine|shop|datenschutz] # analyse = nur AVV und Leistungsbeschreibung der Website-Analyse (Fassung ANALYSE_VERSION), andere PDFs bleiben unveraendert # termine = Leistungsbeschreibung, AVV und Widerrufsbelehrung des Terminplaners (Fassung TERMINE_VERSION, Standard: config.termine.avvVersion); # die HTML-Seiten kommen vorher aus `node scripts/build-legal-html.mjs termine`. Neben jedem PDF liegt .sha256 mit der # SHA-256 der HTML-Seite, aus der es gedruckt wurde (test/buildLegalHtml.test.js prueft, dass beide zusammenpassen). Ein PDF, # dessen Pruefsumme noch stimmt, wird nicht neu gedruckt (FORCE=1 druckt alle neu). -# datenschutz = nur die Datenschutzerklaerung (docs/WEBklar-Datenschutzerklaerung.pdf) +# shop = Leistungsbeschreibung und AVV des Webshops (Fassung SHOP_VERSION, Standard: config.shop.fassung); die HTML-Seiten kommen vorher +# aus `node scripts/build-legal-html.mjs shop`, Pruefsumme und Neudruck wie bei termine. Die Widerrufsbelehrung der Funktionen +# gehoert zu termine (dieselbe Datei) und wird hier nicht gedruckt. `all` druckt auch diese beiden PDFs. +# datenschutz = nur die Datenschutzerklaerung (docs/WEBklar-Datenschutzerklaerung.pdf); daneben liegt .sha256 mit der SHA-256 von +# public/datenschutz.html (test/legalTexts.test.js prueft sie); ein PDF mit passender Pruefsumme wird nicht neu gedruckt (FORCE=1 druckt neu) # Vorher `free -m` pruefen und mit `nice` aufrufen; jeder Chrome-Aufruf laeuft hoechstens 120 Sekunden (timeout), die PDFs nacheinander. set -euo pipefail cd "$(dirname "$0")/.." @@ -33,6 +37,34 @@ termine_version() { echo "$v" } +# Fassung des Webshops: SHOP_VERSION, sonst die Konstante des Servers +shop_version() { + local v="${SHOP_VERSION:-}" + if [ -z "$v" ]; then + v="$(timeout 60 node --max-old-space-size=256 --input-type=module -e "const { config } = await import('./server/config.js'); console.log(config.shop.fassung)")" + fi + [[ "$v" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ ]] || { echo "Ungueltige Fassung: $v" >&2; exit 1; } + echo "$v" +} + +# Leistungsbeschreibung und AVV des Webshops drucken (je PDF nur, wenn die Pruefsumme der HTML-Seite nicht mehr passt) +shop_pdfs() { + local fassung slug quelle ziel summe + fassung="$(shop_version)" + for slug in leistungsbeschreibung-webshop avv-webshop; do + [ -f "public/rechtliches/$slug-$fassung.html" ] || { echo "HTML fehlt: public/rechtliches/$slug-$fassung.html (zuerst node scripts/build-legal-html.mjs shop)"; exit 1; } + quelle="public/rechtliches/$slug-$fassung.html" + ziel="public/rechtliches/$slug-$fassung.pdf" + summe="$(sha256sum "$quelle" | cut -d' ' -f1)" + if [ -z "${FORCE:-}" ] && [ -f "$ziel" ] && [ "$(cat "$ziel.sha256" 2>/dev/null)" = "$summe" ]; then + echo "= $ziel (unveraendert)" + else + pdf "$quelle" "$ziel" + echo "$summe" > "$ziel.sha256" + fi + done +} + case "$MODE" in analyse) pdf "public/rechtliches/avv-website-analyse-$ANALYSE_VERSION.html" "public/rechtliches/avv-website-analyse-$ANALYSE_VERSION.pdf" @@ -53,16 +85,28 @@ termine) fi done ;; +shop) + shop_pdfs + ;; datenschutz) - pdf public/datenschutz.html docs/WEBklar-Datenschutzerklaerung.pdf + quelle="public/datenschutz.html" + ziel="docs/WEBklar-Datenschutzerklaerung.pdf" + summe="$(sha256sum "$quelle" | cut -d' ' -f1)" + if [ -z "${FORCE:-}" ] && [ -f "$ziel" ] && [ "$(cat "$ziel.sha256" 2>/dev/null)" = "$summe" ]; then + echo "= $ziel (unveraendert)" + else + pdf public/datenschutz.html docs/WEBklar-Datenschutzerklaerung.pdf + echo "$summe" > "$ziel.sha256" + fi ;; all) pdf docs/leitfaden-empfehlungsgeber.html docs/WEBklar-Leitfaden-Empfehlungsgeber.pdf # Fassung 3 (public/WEBklar-Wartungs-Abo-Vertragsunterlagen.pdf, Archiv unter public/vertragsunterlagen/v3/) wird nicht neu erzeugt pdf public/abo-bedingungen.html public/WEBklar-Abo-Vertragsunterlagen.pdf pdf public/datenschutz.html docs/WEBklar-Datenschutzerklaerung.pdf + shop_pdfs ;; *) - echo "Usage: $0 [all|analyse|termine|datenschutz]"; exit 1 + echo "Usage: $0 [all|analyse|termine|shop|datenschutz]"; exit 1 ;; esac diff --git a/scripts/feature-consent-felder-leeren.mjs b/scripts/feature-consent-felder-leeren.mjs new file mode 100644 index 0000000..e3ccad8 --- /dev/null +++ b/scripts/feature-consent-felder-leeren.mjs @@ -0,0 +1,92 @@ +#!/usr/bin/env node +/** + * Einmalig (TP2 Abschlusspruefung): IP und Browser der Bestellung von den Funktionsdokumenten (portalFeatures.consentIp, + * consentUserAgent) leeren. Neue Bestellungen speichern sie dort nicht mehr; der Nachweis (featureConsents) traegt sie und hat die + * Loeschfrist der Datenschutzerklaerung Abschnitt 7 (drei Jahre nach Ablauf des Jahres, in dem das letzte Abo endet). Altbestand am + * Funktionsdokument hatte keine Loeschung. + * + * Geleert wird nur dort, wo der Nachweis die Angabe wirklich traegt: ein featureConsents-Eintrag zur Stripe-Sitzung des Dokuments, dessen + * ip bzw. userAgent gesetzt ist, wo das Dokument sie hat. Ohne solchen Nachweis (Bestellung vor dem Nachweis, keine Sitzung) ist die Angabe + * am Dokument der einzige Beleg; sie bleibt stehen und wird im Bericht genannt (die Loeschfrist im Aufbewahrungslauf, services/retention.js, + * leert sie spaeter). consentText und consentAt bleiben: sie sind kein Personenbezug, der Aufbewahrungslauf leert sie mit. + * + * Standard ist der Probelauf: zaehlt und listet, schreibt nichts. + * node scripts/feature-consent-felder-leeren.mjs (= --dry-run) + * node scripts/feature-consent-felder-leeren.mjs --dry-run + * node scripts/feature-consent-felder-leeren.mjs --apply (leert consentIp und consentUserAgent; danach Bericht) + * Im Container als Datei: docker exec -w /app node scripts/feature-consent-felder-leeren.mjs + * NICHT gegen Produktion ausfuehren, ohne dass der Probelauf geprueft und die Freigabe da ist. + */ +import 'dotenv/config' +import { pathToFileURL } from 'node:url' +import { config } from '../server/config.js' +import { listAllDocuments, updateDocument } from '../server/services/appwriteAdmin.js' +import { zustimmungFuerSitzung } from '../server/services/featureConsents.js' + +/** Aufrufargumente: keine oder --dry-run => Probelauf, --apply => schreiben; alles andere (auch beide zusammen) ist ein Fehler. */ +export function parseArgs(argv = []) { + const flags = new Set(argv) + const unknown = argv.filter((a) => a !== '--apply' && a !== '--dry-run') + if (unknown.length) throw new Error(`Unbekanntes Argument: ${unknown.join(' ')} (erlaubt: --dry-run, --apply)`) + if (flags.has('--apply') && flags.has('--dry-run')) throw new Error('--apply und --dry-run schliessen sich aus') + return { apply: flags.has('--apply') } +} + +/** Traegt der Nachweis jede Angabe, die am Funktionsdokument steht? */ +const nachweisTraegt = (doc, nachweis) => (!doc.consentIp || Boolean(nachweis.ip)) && (!doc.consentUserAgent || Boolean(nachweis.userAgent)) + +/** + * @param {{ apply?: boolean, log?: (line: string) => void }} [options] + * @returns {Promise<{ applied: boolean, total: number, withData: number, clearable: string[], kept: string[], unreadable: string[], + * cleared: number, failed: { id: string, reason: string }[] }>} + */ +export async function run({ apply = false, log = console.log } = {}) { + const docs = await listAllDocuments(config.collections.portalFeatures) + const withData = docs.filter((d) => d.consentIp || d.consentUserAgent) + const clearable = [] + const kept = [] + const unreadable = [] + for (const doc of withData) { + try { + const nachweis = await zustimmungFuerSitzung(doc.stripeSessionId) + if (nachweis && nachweisTraegt(doc, nachweis)) clearable.push(doc.$id) + else kept.push(doc.$id) + } catch (err) { + unreadable.push(doc.$id) + log(`Nachweis zu Dokument ${doc.$id} nicht lesbar: ${err.message}`) + } + } + log(`Funktionsdokumente: ${docs.length}, davon mit IP/Browser der Bestellung: ${withData.length}`) + log(` zu leeren (der Nachweis in featureConsents traegt die Angabe): ${clearable.length}`) + log(` bleiben (kein tragender Nachweis, die Angabe am Dokument ist der einzige Beleg): ${kept.length}${kept.length ? ` (${kept.join(', ')})` : ''}`) + if (unreadable.length) log(` nicht geprueft (Nachweis nicht lesbar, bleiben): ${unreadable.length} (${unreadable.join(', ')})`) + + const failed = [] + let cleared = 0 + if (!apply) { + log('Probelauf (--dry-run): es wird nichts geschrieben. Mit --apply werden die oben genannten Dokumente geleert.') + return { applied: false, total: docs.length, withData: withData.length, clearable, kept, unreadable, cleared, failed } + } + for (const id of clearable) { + try { + await updateDocument(config.collections.portalFeatures, id, { consentIp: '', consentUserAgent: '' }) + cleared += 1 + } catch (err) { + failed.push({ id, reason: err.message }) + log(`Dokument ${id} nicht geleert: ${err.message}`) + } + } + log(`Geleert: ${cleared} von ${clearable.length}${failed.length ? `, fehlgeschlagen: ${failed.length} (Skript erneut ausfuehren)` : ''}`) + return { applied: true, total: docs.length, withData: withData.length, clearable, kept, unreadable, cleared, failed } +} + +if (import.meta.url === pathToFileURL(process.argv[1] || '').href) { + try { + const { apply } = parseArgs(process.argv.slice(2)) + const result = await run({ apply }) + if (result.failed.length || result.unreadable.length) process.exitCode = 1 + } catch (err) { + console.error('Fehler:', err.message) + process.exit(1) + } +} diff --git a/scripts/feature-setup.mjs b/scripts/feature-setup.mjs index 580e887..b0c8215 100644 --- a/scripts/feature-setup.mjs +++ b/scripts/feature-setup.mjs @@ -53,6 +53,8 @@ export const FEATURE_CONSENT_ATTRIBUTES = [ export const FEATURE_CONSENT_INDEXES = [ { key: 'idx_session', type: 'key', attributes: ['stripeSessionId'] }, { key: 'idx_project_feature', type: 'key', attributes: ['projectId', 'featureKey', 'acceptedAt'] }, + // Loeschfristen (services/retention.js): alle Nachweise eines Kunden, drei Jahre nach Ablauf des Jahres, in dem sein letztes Abo endet + { key: 'idx_customer', type: 'key', attributes: ['customerId'] }, ] /** true, wenn das Ergebnis von main() Luecken meldet (Exit-Code 1 im Pruefmodus) */ diff --git a/scripts/shop-setup.mjs b/scripts/shop-setup.mjs new file mode 100644 index 0000000..27ae94b --- /dev/null +++ b/scripts/shop-setup.mjs @@ -0,0 +1,85 @@ +#!/usr/bin/env node +/** + * Webshop: legt die fuenf Collections shopConfig, shopProducts, shopOrders, shopWiderrufe und shopRechtstexteArchiv mit + * Attributen und Indizes in Appwrite an (nur Server-Key, keine Client-Rechte). Das Schema steht in + * server/services/shop/schema.js. Idempotent: Vorhandenes wird uebersprungen, fehlende Attribute und Indizes + * auch in schon vorhandenen Collections nachgezogen. Vor dem Einschalten (SHOP_ENABLED) ausfuehren. + * Usage: + * node scripts/shop-setup.mjs --check (nur melden, nichts anlegen; Exit 1 bei Fehlendem) + * node scripts/shop-setup.mjs (Fehlendes anlegen) + */ +import { pathToFileURL } from 'node:url' +import { config } from '../server/config.js' +import { SHOP_COLLECTIONS } from '../server/services/shop/schema.js' +import { createAppwriteClient } from './analyse-setup.mjs' + +export async function main({ check = false, client, log = console.log } = {}) { + const api = client || createAppwriteClient() + const missing = [] + const missingCollections = [] + const missingIndexes = [] + const plan = [] + + // Erst alles pruefen, dann (ohne --check) anlegen, damit --check garantiert nichts veraendert + for (const [name, { attributes, indexes }] of Object.entries(SHOP_COLLECTIONS)) { + const id = config.collections[name] + const exists = await api.collectionExists(id) + const haveAttributes = exists ? new Set((await api.listAttributes(id)).map((a) => a.key)) : new Set() + const haveIndexes = exists ? new Set((await api.listIndexes(id)).map((i) => i.key)) : new Set() + const absentAttributes = attributes.filter((a) => !haveAttributes.has(a.key)) + const absentIndexes = indexes.filter((i) => !haveIndexes.has(i.key)) + + if (!exists) { + log(`${check ? '-' : '+'} Collection ${id}${check ? ' FEHLT' : ''}`) + missingCollections.push(id) + } else { + log(`= ${id}: ${haveAttributes.size} Attribute, ${haveIndexes.size} Indizes vorhanden`) + } + if (check) { + absentAttributes.forEach((a) => log(`- ${id}.${a.key} FEHLT`)) + absentIndexes.forEach((i) => log(`- Index ${id}.${i.key} FEHLT`)) + } + absentAttributes.forEach((a) => missing.push({ ...a, collection: id })) + absentIndexes.forEach((i) => missingIndexes.push({ ...i, collection: id })) + plan.push({ id, exists, absentAttributes, absentIndexes }) + } + + const result = { missing, missingCollections, missingIndexes } + if (check) { + const open = missing.length + missingCollections.length + missingIndexes.length + log(open ? `${open} Eintrag/Eintraege fehlen.` : 'Alles vorhanden.') + return result + } + + for (const { id, exists, absentAttributes } of plan) { + if (!exists) await api.ensureCollection(id, id) + for (const attr of absentAttributes) { + await api.createAttribute(id, attr) + log(`+ Attribut ${id}.${attr.key}`) + } + } + // Indizes brauchen verfuegbare Attribute: erst warten, dann anlegen + for (const { id, absentAttributes } of plan) { + for (const attr of absentAttributes) await api.waitForAttribute(id, attr.key) + } + for (const { id, absentIndexes } of plan) { + for (const index of absentIndexes) { + await api.createIndex(id, index) + log(`+ Index ${id}.${index.key}`) + } + } + log('Fertig.') + return result +} + +const invokedDirectly = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href +if (invokedDirectly) { + const check = process.argv.includes('--check') + main({ check }) + .then(({ missing, missingCollections, missingIndexes }) => + process.exit(check && (missing.length || missingCollections.length || missingIndexes.length) ? 1 : 0)) + .catch((e) => { + console.error(e.message) + process.exit(1) + }) +} diff --git a/scripts/stripe-products.mjs b/scripts/stripe-products.mjs index 5ccb298..53377ca 100644 --- a/scripts/stripe-products.mjs +++ b/scripts/stripe-products.mjs @@ -1,6 +1,6 @@ #!/usr/bin/env node /** - * Stripe-Preise je Produkt (Wartungs-Abo, Hosting-Abo, E-Mail-Zusatzspeicher, News-, Terminplaner- und Oeffnungszeiten-Einrichtung als Einmalpreise): legt fehlende + * Stripe-Preise je Produkt (Wartungs-Abo, Hosting-Abo, E-Mail-Zusatzspeicher, News-, Terminplaner-, Webshop- und Oeffnungszeiten-Einrichtung als Einmalpreise): legt fehlende * Preise an (ensurePrice), haelt die Wartungs-Produktbeschreibung bei Stripe auf dem Katalogtext * (server/config.js) und passt die Kundenportal-Konfiguration an ("WEBklar – Abo verwalten"). * Idempotent: vorhandene Preise werden ueber lookup_key gefunden, nicht neu angelegt. @@ -17,7 +17,7 @@ import { config } from '../server/config.js' import { ensurePortalConfiguration, ensurePrice, getStripe, PORTAL_HEADLINE } from '../server/services/stripeBilling.js' import { PRODUCT_KEYS } from '../server/services/products.js' -export const KEYS = [...PRODUCT_KEYS, 'mailStorage', 'newsSetup', 'termineSetup', 'zeitenSetup'] // ['wartung', 'hosting', 'mailStorage', 'newsSetup', 'termineSetup', 'zeitenSetup'] +export const KEYS = [...PRODUCT_KEYS, 'mailStorage', 'newsSetup', 'termineSetup', 'shopSetup', 'zeitenSetup'] // ['wartung', 'hosting', 'mailStorage', 'newsSetup', 'termineSetup', 'shopSetup', 'zeitenSetup'] const USAGE = 'Usage: node scripts/stripe-products.mjs --pruefen | --anlegen' export function parseArgs(argv) { @@ -31,6 +31,7 @@ export function catalogEntry(key) { if (key === 'mailStorage') return config.mailStorage if (key === 'newsSetup') return config.newsSetup if (key === 'termineSetup') return config.termineSetup + if (key === 'shopSetup') return config.shopSetup if (key === 'zeitenSetup') return config.zeitenSetup return config.products[key] } diff --git a/scripts/termine-texte-archiv.mjs b/scripts/termine-texte-archiv.mjs index 844e89c..149404a 100644 --- a/scripts/termine-texte-archiv.mjs +++ b/scripts/termine-texte-archiv.mjs @@ -91,7 +91,7 @@ export function baueArchiv() { 'Die Haken stehen immer in der Sie-Form, auch bei Betrieben, die ihre Gäste duzen.', '### Vorzeitiger Beginn (Pflicht bei einer Leistung mit Preis für Verbraucher, wenn der Termin vor Ablauf der Widerrufsfrist beginnt, bei Terminanfragen immer)', zitat(ZUSTIMMUNG_VORZEITIG(PLATZHALTER_BETRIEB.name)), - '### Stornobedingungen (Pflicht, wenn der Betrieb Stornobedingungen hat; der Text der Bedingungen steht beim Betrieb und trägt eine eigene Version)', + '### Stornobedingungen (Pflicht bei einer Leistung mit Preis, wenn der Betrieb Stornobedingungen hat; bei kostenlosen Leistungen fragt der Terminplaner nicht danach; der Text der Bedingungen steht beim Betrieb und trägt eine eigene Version)', zitat(ZUSTIMMUNG_STORNO), '### Erinnerung (freiwillig, nie vorangekreuzt, nur wenn der Betrieb Erinnerungen anbietet)', zitat(ZUSTIMMUNG_ERINNERUNG(PLATZHALTER_STUNDEN)), diff --git a/server/config.js b/server/config.js index f727f15..f300977 100644 --- a/server/config.js +++ b/server/config.js @@ -30,6 +30,7 @@ function nonNegativeNumber(raw, fallback) { } const termineEnabled = (process.env.TERMINE_ENABLED || '').trim().toLowerCase() +const shopEnabled = (process.env.SHOP_ENABLED || '').trim().toLowerCase() const zeitenEnabled = (process.env.ZEITEN_ENABLED || '').trim().toLowerCase() /** Komma-Liste aus einer Umgebungsvariablen: getrimmt, klein geschrieben, ohne Leereintraege; leer ergibt den Vorgabewert. */ @@ -126,6 +127,16 @@ const termineSetup = { priceNickname: 'Terminplaner-Einrichtung 149 €', } +// Webshop: einmalige Einrichtung (Einmalpreis, kein Abo); der Shop selbst verkauft ueber das eigene Stripe-Konto des Kunden (Connect) +const shopSetup = { + key: 'shopSetup', + name: 'Webshop – Einrichtung', + amountCents: 29900, + lookupKey: 'webklar_shop_einrichtung_29900', + oneTime: true, + priceNickname: 'Webshop-Einrichtung 299 €', +} + // Oeffnungszeiten live: einmalige Einrichtung (Einmalpreis, kein Abo); im Wartungs-Abo ist die Funktion enthalten const zeitenSetup = { key: 'zeitenSetup', @@ -242,7 +253,85 @@ function termineConsent() { } /** - * Register der kaufbaren Funktionen (News-Pinnwand, Terminplaner): Preis, Zustimmungen, Rechtsunterlagen, + * Rechtsunterlage des Webshops: wie termineRechtsunterlage, aber mit Fassung und Adresse aus config.shop.fassung (bei jedem + * Zugriff gelesen). Slugs wie in services/legalDocs.js (SAETZE.shop); test/legalDocs.test.js gleicht beide ab. + */ +function shopRechtsunterlage(slug, { nullIstWert = false } = {}) { + let ueberschrieben // undefined = Standard + const standard = () => ({ version: config.shop.fassung, url: `/rechtliches/${slug}-${config.shop.fassung}.html` }) + return { + get: () => (ueberschrieben === undefined ? standard() : ueberschrieben), + set(wert) { + const std = standard() + const zurueck = wert === undefined || (wert === null && !nullIstWert) || (wert !== null && wert.version === std.version && wert.url === std.url) + ueberschrieben = zurueck ? undefined : wert + }, + } +} +const shopLb = shopRechtsunterlage('leistungsbeschreibung-webshop') +const shopAvv = shopRechtsunterlage('avv-webshop') +const shopWiderruf = shopRechtsunterlage('widerrufsbelehrung-funktionen', { nullIstWert: true }) + +const SHOP_PREIS = '299 €' +const SHOP_DAUER = 'meist innerhalb von 2–3 Wochen' + +/** + * Bestelltexte des Webshops: fuenf Haken (alle Besteller), ohne Adressen im Text (die versionierten Links zeigt die + * Bestellseite daneben aus bestellung.dokumente). Die Fassung (consent.version) ist config.shop.fassung; die dynamischen Teile + * (Fassung, Preis, Umsatzsteuer-Hinweis, Dauer) stehen fertig im Text, gespeichert wird immer dieser Text. Aufbau wie + * termineConsent (Haken je Fassung einmal gebaut; Tests duerfen einzelne Texte aendern und zurueckstellen). Bei Textaenderung + * neues Fassungsdatum und neuen Hash im Pin von test/featureConsentTexte.test.js. Zitat und Wertersatz-Satz von "beginn" und + * die Pflichtinformationen sind Entwurf bis zur Kanzlei. + */ +function shopConsent() { + let versionGesetzt // undefined = config.shop.fassung + let gebaut = null // { version, items } + const version = () => versionGesetzt ?? config.shop.fassung + const items = (v) => [ + { + id: 'unterlagen', + haken: true, + text: `Ich stimme der Leistungsbeschreibung Webshop und dem Auftragsverarbeitungsvertrag Webshop in der Fassung ${v} zu.`, + }, + { + id: 'verantwortung', + haken: true, + text: 'Ich verkaufe als Unternehmer in eigenem Namen. Für meine Produkte, Preise, Produktangaben und Rechtstexte (AGB, Widerrufsbelehrung, Impressum, Datenschutzerklärung) bin ich selbst verantwortlich; WEBklar stellt Muster bereit und berät nicht rechtlich. Ich verkaufe keine der in der Leistungsbeschreibung ausgeschlossenen Waren.', + }, + { + id: 'stripe', + haken: true, + text: 'Die Zahlungen meiner Käufer laufen über mein eigenes Stripe-Konto zu den Bedingungen von Stripe. WEBklar erhält und verwahrt kein Geld meiner Käufer und erhebt keine Umsatzbeteiligung.', + }, + { + id: 'beginn', + haken: true, + text: 'Ich verlange ausdrücklich, dass WEBklar vor Ablauf der Widerrufsfrist mit der Einrichtung des Webshops beginnt. Mir ist bekannt, dass mein Widerrufsrecht mit der vollständigen Erbringung der Einrichtung erlischt (§ 356 Abs. 5 Nr. 2 BGB). Widerrufe ich vor der vollständigen Erbringung, schulde ich einen angemessenen Betrag für die bis dahin erbrachte Leistung (§ 357a Abs. 2 BGB).', + }, + { + id: 'information', + haken: true, + text: `Ich habe die Pflichtinformationen zu dieser Bestellung gelesen: Leistung (Einrichtung des Webshops für diese Website: Einbau des Shop-Widgets, Verwaltung im Portal, Muster-Rechtstexte), Gesamtpreis ${SHOP_PREIS} einmalig (${ustHinweis}), voraussichtliche Dauer ${SHOP_DAUER}, keine laufende Gebühr von WEBklar; der Webshop läuft, solange WEBklar die Website betreibt. Die Widerrufsbelehrung mit Muster-Widerrufsformular habe ich gelesen.`, + }, + ] + return { + get version() { + return version() + }, + set version(wert) { + versionGesetzt = wert === undefined || wert === null || wert === config.shop.fassung ? undefined : wert + }, + knopf: `Zahlungspflichtig bestellen · ${SHOP_PREIS}`, + get items() { + const v = version() + if (gebaut?.version !== v) gebaut = { version: v, items: items(v) } + return gebaut.items + }, + } +} + +/** + * Register der kaufbaren Funktionen (News-Pinnwand, Terminplaner, Webshop): Preis, Zustimmungen, Rechtsunterlagen, * Ticket- und Mail-Texte je Funktion; reine Daten, config.js importiert keine Dienste. Die Helfer (featureConfig, * featureHaken, oeffentlicheAnsicht) stehen in server/services/featureRegistry.js. Haken (verfuegbar, * bestellpruefung, unterlagenBereit, belehrung, freischaltbar) gehoeren nicht hierher: wer sie in Tests auf dem @@ -369,6 +458,58 @@ const features = { orderedConflictMessage: 'Der Terminplaner ist für dieses Projekt schon bestellt.', unavailableMessage: 'Der Terminplaner ist noch nicht verfügbar.', }, + shop: { + key: 'shop', + product: 'shopSetup', + label: { name: 'Webshop', kurz: 'Shop', keine: 'keinen Webshop' }, + priceLabel: SHOP_PREIS, + setupEstimate: SHOP_DAUER, + consent: shopConsent(), + // Fassung und Adresse folgen config.shop.fassung bei jedem Zugriff (Slugs wie in services/legalDocs.js; config.js + // importiert keine Dienste, test/legalDocs.test.js gleicht beide ab) + get leistungsbeschreibung() { + return shopLb.get() + }, + set leistungsbeschreibung(wert) { + shopLb.set(wert) + }, + get avv() { + return shopAvv.get() + }, + set avv(wert) { + shopAvv.set(wert) + }, + get widerruf() { + return shopWiderruf.get() + }, + set widerruf(wert) { + shopWiderruf.set(wert) + }, + returnHash: 'webshop', + returnProjekt: true, + ticket: { + topic: (project) => `Webshop einbauen – ${ticketProjektName(project)}`, + // Dienst erst beim Aufruf laden: config.js importiert keine Dienste statisch + details: async (ctx) => { + const { shopTicketDetails } = await import('./services/shop/ticketDetails.js') + return shopTicketDetails(ctx) + }, + }, + mails: { + funktion: 'Webshop', + formen: { dein: 'Dein Webshop', nom: 'der Webshop', akk: 'den Webshop', gen: 'des Webshops', pron: 'ihn' }, + knopf: 'Webshop öffnen', + danach: 'Danach kannst Du im Portal Artikel anlegen, Dein Stripe-Konto verbinden und den Shop öffnen.', + bereit: 'Dein Webshop ist eingebaut. Sobald Stripe-Konto, Artikel und Rechtstexte fertig sind, kannst Du ihn im Portal öffnen.', + preisHinweis: `einmalig, ${ustHinweis}; keine laufende Gebühr von WEBklar, Stripe-Gebühren zahlst Du direkt an Stripe`, + anleitung: false, + }, + requiresWartungsAbo: false, + ticketFreischaltung: true, + disabledMessage: 'Der Webshop ist für diese Website abgeschaltet. Schreib uns im Chat, wenn Du ihn wieder möchtest.', + orderedConflictMessage: 'Der Webshop ist für dieses Projekt schon bestellt.', + unavailableMessage: 'Der Webshop ist noch nicht verfügbar.', + }, // Oeffnungszeiten live (Spec 2026-10-04): 30 EUR einmalig, im Wartungs-Abo ohne Aufpreis (aboInklusive, consentFrei) zeiten: { key: 'zeiten', @@ -484,6 +625,12 @@ export const config = { termineBookings: process.env.APPWRITE_COLLECTION_TERMINE_BOOKINGS || 'termineBookings', termineClaims: process.env.APPWRITE_COLLECTION_TERMINE_CLAIMS || 'termineClaims', termineOutbox: process.env.APPWRITE_COLLECTION_TERMINE_OUTBOX || 'termineOutbox', + // Webshop (Einstellungen, Artikel, Bestellungen, Widerrufe, Archiv der bestaetigten Rechtstexte; scripts/shop-setup.mjs) + shopConfig: process.env.APPWRITE_COLLECTION_SHOP_CONFIG || 'shopConfig', + shopProducts: process.env.APPWRITE_COLLECTION_SHOP_PRODUCTS || 'shopProducts', + shopOrders: process.env.APPWRITE_COLLECTION_SHOP_ORDERS || 'shopOrders', + shopWiderrufe: process.env.APPWRITE_COLLECTION_SHOP_WIDERRUFE || 'shopWiderrufe', + shopRechtstexteArchiv: process.env.APPWRITE_COLLECTION_SHOP_RECHTSTEXTE_ARCHIV || 'shopRechtstexteArchiv', // Oeffnungszeiten live (Einstellungen, Erinnerungsmerker; scripts/zeiten-setup.mjs) oeffnungszeiten: process.env.APPWRITE_COLLECTION_OEFFNUNGSZEITEN || 'oeffnungszeiten', zeitenErinnerungen: process.env.APPWRITE_COLLECTION_ZEITEN_ERINNERUNGEN || 'zeitenErinnerungen', @@ -503,6 +650,7 @@ export const config = { mailStorage, newsSetup, termineSetup, + shopSetup, zeitenSetup, ustHinweis, // Register der kaufbaren Funktionen (siehe oben) @@ -733,6 +881,40 @@ export const config = { minAntwortMs: nonNegativeNumber(process.env.TERMINE_MIN_ANTWORT_MS, 300), }, + // Webshop (Funktionsschluessel 'shop'). Wird bei jedem Aufruf gelesen (Tests aendern die Werte zur Laufzeit); + // Schalter siehe server/services/shop/schalter.js + shop: { + // off (Vorgabe) | pilot (nur SHOP_PILOT_EMAILS) | on (alle Kunden) + mode: ['pilot', 'on'].includes(shopEnabled) ? shopEnabled : 'off', + // Kunden-E-Mails fuer den Pilotbetrieb; '*' = alle + pilotEmails: (process.env.SHOP_PILOT_EMAILS || '') + .split(',') + .map((s) => s.trim().toLowerCase()) + .filter(Boolean), + // Fassung der Webshop-Bestellunterlagen (Leistungsbeschreibung, AVV, Bestelltexte): Berliner Kalendertag des + // Fassungs-Commits (JJJJ-MM-TT); eine ausgelieferte Fassung wird nie ueberschrieben + fassung: '2026-10-05', + // Artikelbilder der Shops: // + uploadDir: process.env.SHOP_UPLOAD_DIR || path.join(process.cwd(), 'upload-data', 'shop'), + // Signing-Secret des Stripe-Connect-Webhooks (Ereignisse der verbundenen Konten); leer = Webhook antwortet 503 + connectWebhookSecret: process.env.STRIPE_CONNECT_WEBHOOK_SECRET || '', + // Eine unbezahlte Bestellung haelt den Bestand (und die Stripe-Checkout-Sitzung) so lange + bestellAblaufMinuten: 30, + maxArtikel: 500, + maxBilder: 4, + maxVarianten: 30, + // Bestellungen werden so viele Jahre nach Abschluss anonymisiert (Aufbewahrungsfrist), siehe Block J + anonymisierenJahre: 3, + // Mindestzeit (Sekunden) zwischen Laden und Absenden des Widerrufs-/Bestellformulars (schneller = Bot) + formMinSekunden: nonNegativeNumber(process.env.SHOP_FORM_MIN_SEKUNDEN, 3), + // Kuerzeste Antwortzeit (ms) der POST-Routen der oeffentlichen Schnittstelle (verwischt Zeitunterschiede). Tests setzen 0. + minAntwortMs: nonNegativeNumber(process.env.SHOP_MIN_ANTWORT_MS, 300), + // Tage nach Abschalten der Funktion bis zur Loeschung der Shop-Daten + loeschTageNachAbschalten: 90, + // Unbezahlte (offene/stornierte) Bestellungen werden nach so vielen Tagen geloescht + unbezahltLoeschTage: 30, + }, + // Oeffnungszeiten live (Funktionsschluessel 'zeiten'); Geschwister von termine (termineSchalter.test.js pinnt // config.termine). Wird bei jedem Aufruf gelesen; Schalter siehe server/services/zeiten/schalter.js zeiten: { diff --git a/server/index.js b/server/index.js index 55e52a2..49a6335 100644 --- a/server/index.js +++ b/server/index.js @@ -17,6 +17,9 @@ import chatRoutes from './routes/chat.js' import aboRoutes from './routes/abo.js' import funktionenRoutes from './routes/funktionen.js' import termineRoutes from './routes/termine.js' +import shopRoutes from './routes/shop.js' +import shopWebhookRoutes from './routes/shopWebhook.js' +import { shopSeiteRouter } from './routes/shopSeite.js' import zeitenRoutes from './routes/zeiten.js' import takeoverCreditRoutes from './routes/takeoverCredits.js' import { purgeExpiredUploads } from './services/takeoverCredits.js' @@ -26,6 +29,7 @@ import { purgeUnclaimedNewsImages } from './services/newsImages.js' import { runActivation } from './services/siteFeatures.js' import { runAboCheck, runAnalyseReconcile } from './services/analyse.js' import { fuenfMinutenJob, minutenJob, tagesbericht, taeglicherJob } from './services/termine/jobs.js' +import { minutenJob as shopMinutenJob, stuendlicherJob as shopStuendlicherJob, taeglicherJob as shopTaeglicherJob } from './services/shop/jobs.js' import { zeitenAboJob, zeitenErinnerungsJob } from './services/zeiten/jobs.js' import giteaWebhookRoutes from './routes/webhook/gitea.js' import pushTimeRoutes from './routes/pushTime.js' @@ -39,6 +43,7 @@ import adminInvoiceClientsRoutes from './routes/admin/invoiceClients.js' import newsRoutes from './routes/news.js' import publicNewsRoutes from './routes/publicNews.js' import publicTermineRoutes from './routes/publicTermine.js' +import publicShopRoutes from './routes/publicShop.js' import publicZeitenRoutes from './routes/publicZeiten.js' import adminAnalyseRoutes from './routes/admin/analyse.js' import { legalRouter } from './services/legalDocs.js' @@ -62,6 +67,8 @@ app.use(kundenansichtGuard) // Oeffentliche Schnittstelle des Terminplaners (Gaeste ohne Session): bringt Parser (16 KB) und Fehlerbehandlung mit und // haengt deshalb VOR dem globalen Parser (2 MB), der ihre Rumpfe sonst zuerst lesen wuerde app.use('/api/public/termine/v1', publicTermineRoutes) +// Oeffentliche Schnittstelle des Webshops (Kaeufer ohne Session): wie die des Terminplaners mit eigenem Parser (16 KB), daher ebenfalls VOR dem globalen Parser +app.use('/api/public/shop/v1', publicShopRoutes) // Uebernahmegutschrift: Nachweise kommen als Base64 im JSON (max. 10 MB Dateien) -> eigenes Limit, // muss vor dem globalen Parser stehen (der ueberspringt bereits geparste Bodies). Login-Pruefung // zuerst, damit niemand ohne Session grosse Bodies parsen laesst. @@ -83,6 +90,9 @@ app.use('/api/abo/takeover-credits', takeoverCreditRoutes) app.use('/api/abo', aboRoutes) app.use('/api/funktionen', funktionenRoutes) app.use('/api/termine', termineRoutes) +// Stripe-Connect-Webhook des Webshops (ohne Sitzung): vor dem Router, braucht rawBody des globalen Parsers +app.use('/api/shop/stripe-webhook', shopWebhookRoutes) +app.use('/api/shop', shopRoutes) app.use('/api/zeiten', zeitenRoutes) app.use('/api/team', teamRoutes) app.use('/api/admin/gitea', adminGiteaRoutes) @@ -117,6 +127,8 @@ app.use('/embed/', (_req, res, next) => { }) // Adressen ohne Fassung: aktuelle Fassung der Rechtstexte (Analyse und Terminplaner; vor express.static) app.use('/rechtliches', legalRouter()) +// Webshop-Seiten fuer Kaeufer: /shop/:siteKey und /shop/:siteKey/widerruf (vor express.static) +app.use(shopSeiteRouter()) // Abgeschaltetes Admin-Panel und Rechnungsanalyse: Links aus alten Team-Mails ins Ticketsystem app.use('/admin.html', legacyAdminRedirect.admin) app.use('/kalkulation.html', legacyAdminRedirect.kalkulation) @@ -225,6 +237,29 @@ const server = app.listen(config.port, '0.0.0.0', () => { setTimeout(termineTagesJob, 180 * 1000).unref() setInterval(termineTagesJob, 24 * 60 * 60 * 1000).unref() + // Webshop: jede Minute offene Bestellungen mit verstrichener Frist bei Stripe abgleichen (75 s nach dem Start beginnend), stuendlich nie + // beanspruchte Artikelbilder aufraeumen (130 s), taeglich Kaeuferdaten anonymisieren, unbezahlte Bestellungen und lange abgeschaltete + // Shops loeschen (200 s). Bei SHOP_ENABLED=off kehren Minuten- und Stundenjob sofort zurueck; der Tagesjob laeuft dann nur, wenn es + // noch Shop-Dokumente gibt. Die Jobs melden Einzelfehler selbst (ohne Kaeuferdaten); hier faengt der Rahmen alles Unerwartete ab. + const shopMinuten = () => shopMinutenJob(new Date()).catch((err) => console.warn('[shop] Minutenjob fehlgeschlagen:', err.message)) + setTimeout(() => { + shopMinuten() + setInterval(shopMinuten, 60 * 1000).unref() + }, 75 * 1000).unref() + const shopStunden = () => shopStuendlicherJob(new Date()).catch((err) => console.warn('[shop] Stundenjob fehlgeschlagen:', err.message)) + setTimeout(() => { + shopStunden() + setInterval(shopStunden, 60 * 60 * 1000).unref() + }, 130 * 1000).unref() + const shopTages = () => + shopTaeglicherJob(new Date()) + .then((r) => r && !r.aus && !r.uebersprungen && console.log(`[shop] Tagesjob: ${JSON.stringify(r)}`)) + .catch((err) => console.warn('[shop] Tagesjob fehlgeschlagen:', err.message)) + setTimeout(() => { + shopTages() + setInterval(shopTages, 24 * 60 * 60 * 1000).unref() + }, 200 * 1000).unref() + // Oeffnungszeiten live: taeglich der Abo-Abgleich (4 Minuten nach dem Start beginnend; laeuft unabhaengig vom Schalter ZEITEN_ENABLED, // ohne Zeiten-Dokumente kostet er eine Abfrage) und stuendlich die Feiertags-Erinnerung (5 Minuten nach dem Start beginnend; nur bei // Schalter pilot/on und nur 08:00 bis 20:00 Berliner Zeit, sonst kehrt jeder Lauf sofort zurueck). Beide Jobs ueberlappen nicht und diff --git a/server/mail-templates/G09 Bestellbestaetigung.html b/server/mail-templates/G09 Bestellbestaetigung.html new file mode 100644 index 0000000..877e851 --- /dev/null +++ b/server/mail-templates/G09 Bestellbestaetigung.html @@ -0,0 +1,85 @@ + + + + + + + + +Ihre Bestellung {{bestellnummer}} bei {{betrieb_name}} + + + + + + + + + diff --git a/server/mail-templates/G10 Versand.html b/server/mail-templates/G10 Versand.html new file mode 100644 index 0000000..0eaaa59 --- /dev/null +++ b/server/mail-templates/G10 Versand.html @@ -0,0 +1,53 @@ + + + + + + + + +Ihre Bestellung {{bestellnummer}} wurde versendet + + + + + + + + + diff --git a/server/mail-templates/G11 Abholbereit.html b/server/mail-templates/G11 Abholbereit.html new file mode 100644 index 0000000..a2980c7 --- /dev/null +++ b/server/mail-templates/G11 Abholbereit.html @@ -0,0 +1,49 @@ + + + + + + + + +Ihre Bestellung {{bestellnummer}} ist abholbereit + + + + + + + + + diff --git a/server/mail-templates/G12 Eingang Widerruf.html b/server/mail-templates/G12 Eingang Widerruf.html new file mode 100644 index 0000000..4587ca4 --- /dev/null +++ b/server/mail-templates/G12 Eingang Widerruf.html @@ -0,0 +1,55 @@ + + + + + + + + +Eingangsbestätigung Ihres Widerrufs bei {{betrieb_name}} + + + + + + + + + diff --git a/server/mail-templates/G13 Erstattung.html b/server/mail-templates/G13 Erstattung.html new file mode 100644 index 0000000..b2f725d --- /dev/null +++ b/server/mail-templates/G13 Erstattung.html @@ -0,0 +1,53 @@ + + + + + + + + +Erstattung zu Ihrer Bestellung {{bestellnummer}} + + + + + + + + + diff --git a/server/mail-templates/G14 Zahlung fehlgeschlagen.html b/server/mail-templates/G14 Zahlung fehlgeschlagen.html new file mode 100644 index 0000000..6f65b7f --- /dev/null +++ b/server/mail-templates/G14 Zahlung fehlgeschlagen.html @@ -0,0 +1,47 @@ + + + + + + + + +Ihre Bestellung {{bestellnummer}} wurde nicht abgeschlossen + + + + + + + + + diff --git a/server/mail-templates/G15 Bestellung eingegangen.html b/server/mail-templates/G15 Bestellung eingegangen.html new file mode 100644 index 0000000..4fc7528 --- /dev/null +++ b/server/mail-templates/G15 Bestellung eingegangen.html @@ -0,0 +1,53 @@ + + + + + + + + +Wir haben Ihre Bestellung {{bestellnummer}} erhalten + + + + + + + + + diff --git a/server/mail-templates/K24 Webshop-Anfrage.html b/server/mail-templates/K24 Webshop-Anfrage.html new file mode 100644 index 0000000..11a2dc6 --- /dev/null +++ b/server/mail-templates/K24 Webshop-Anfrage.html @@ -0,0 +1,52 @@ + + + + + + + + +Deine Webshop-Anfrage für {{projekt}} + + + + +Deine Webshop-Anfrage wurde bearbeitet. ͏ ͏ ͏ + + + + + diff --git a/server/mail-templates/K25 Neue Bestellung.html b/server/mail-templates/K25 Neue Bestellung.html new file mode 100644 index 0000000..43c48d6 --- /dev/null +++ b/server/mail-templates/K25 Neue Bestellung.html @@ -0,0 +1,64 @@ + + + + + + + + +Neue Bestellung {{bestellnummer}} in Deinem Webshop + + + + +In Deinem Webshop ist eine neue Bestellung eingegangen. ͏ ͏ ͏ + + + + + diff --git a/server/mail-templates/K26 Widerruf im Webshop.html b/server/mail-templates/K26 Widerruf im Webshop.html new file mode 100644 index 0000000..f6b7097 --- /dev/null +++ b/server/mail-templates/K26 Widerruf im Webshop.html @@ -0,0 +1,61 @@ + + + + + + + + +Widerruf in Deinem Webshop eingegangen + + + + +Ein Käufer hat in Deinem Webshop einen Vertrag widerrufen. ͏ ͏ ͏ + + + + + diff --git a/server/mail-templates/T21 Neue Webshop-Anfrage.html b/server/mail-templates/T21 Neue Webshop-Anfrage.html new file mode 100644 index 0000000..0c18a59 --- /dev/null +++ b/server/mail-templates/T21 Neue Webshop-Anfrage.html @@ -0,0 +1,48 @@ + + + + + + + + +[Webshop] Neue Anfrage: {{kunde_name}} + + + + +{{kunde_name}} hat eine Webshop-Anfrage gestellt (Projekt {{projekt}}). ͏ ͏ ͏ + + + + + diff --git a/server/middleware/appwriteStaff.js b/server/middleware/appwriteStaff.js index ba7df49..b9382a4 100644 --- a/server/middleware/appwriteStaff.js +++ b/server/middleware/appwriteStaff.js @@ -1,6 +1,20 @@ import { config } from '../config.js' +import { ADMIN_LABEL, STAFF_LABELS } from '../services/staffLabels.js' -export async function requireAppwriteStaff(req, res, next) { +/** + * Prueft das Appwrite-JWT aus dem Ticketsystem und verlangt eines der Labels `allowed`. + * requireAppwriteStaff: nur "admin" (Kunden, Mitarbeiter, Finanzen ...). + * requireAppwriteTeam: jeder Mitarbeiter ("mitarbeiter" oder "admin") - Projekt-Aktionen + * (Anlegen, Zuordnen, Archivieren, Preview, Analyse, Gitea-Import), Entscheidung 2026-10-04. + */ +function requireAppwriteLabels(allowed, deniedMessage) { + return (req, res, next) => checkAppwriteJwt(req, res, next, allowed, deniedMessage) +} + +export const requireAppwriteStaff = requireAppwriteLabels([ADMIN_LABEL], 'Admin-Berechtigung erforderlich') +export const requireAppwriteTeam = requireAppwriteLabels(STAFF_LABELS, 'Nur für Mitarbeiter') + +async function checkAppwriteJwt(req, res, next, allowed, deniedMessage) { const authHeader = req.get('Authorization') || '' const match = authHeader.match(/^Bearer\s+(.+)$/i) if (!match) { @@ -29,11 +43,11 @@ export async function requireAppwriteStaff(req, res, next) { } if (!response.ok) { - return res.status(401).json({ error: data?.message || 'Ung�ltiges JWT' }) + return res.status(401).json({ error: data?.message || 'Ungültiges JWT' }) } - if (!Array.isArray(data?.labels) || !data.labels.includes('admin')) { - return res.status(403).json({ error: 'Admin-Berechtigung erforderlich' }) + if (!Array.isArray(data?.labels) || !allowed.some((label) => data.labels.includes(label))) { + return res.status(403).json({ error: deniedMessage }) } req.appwriteUser = data diff --git a/server/middleware/cors.js b/server/middleware/cors.js index b5562c2..deab6d5 100644 --- a/server/middleware/cors.js +++ b/server/middleware/cors.js @@ -7,7 +7,7 @@ export function corsMiddleware(allowedOrigins) { if (origin && allowedOrigins.includes(origin)) { res.setHeader('Access-Control-Allow-Origin', origin) res.setHeader('Access-Control-Allow-Headers', ALLOW_HEADERS) - res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PATCH, DELETE, OPTIONS') + res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE, OPTIONS') if (req.method === 'OPTIONS') { return res.sendStatus(204) } diff --git a/server/middleware/kundenansichtGuard.js b/server/middleware/kundenansichtGuard.js index d406bb3..9bd388f 100644 --- a/server/middleware/kundenansichtGuard.js +++ b/server/middleware/kundenansichtGuard.js @@ -24,6 +24,16 @@ export const KUNDENANSICHT_ALLOWED = [ ['POST', /^\/api\/termine\/buchungen\/[^/]+\/aktion\/?$/], ['PATCH', /^\/api\/termine\/einstellungen\/?$/], ['DELETE', /^\/api\/termine\/buchungen\/[^/]+\/?$/], + // Webshop: Artikel, Einstellungen, Rechtstexte-Entwurf und Bilder darf das Team mit dem Kunden ausfuellen (protokolliert). Genau diese + // sieben Pfade, jeder einzeln: Anfrage, Oeffnen, Bestaetigen der Rechtstexte (der Haendler bestaetigt persoenlich), Stripe und + // Erstattungen bleiben gesperrt, ohne dass es einen Eintrag braucht. + ['POST', /^\/api\/shop\/produkte\/?$/], + ['POST', /^\/api\/shop\/produkte\/sortierung\/?$/], + ['PUT', /^\/api\/shop\/produkte\/[^/]+\/?$/], + ['DELETE', /^\/api\/shop\/produkte\/[^/]+\/?$/], + ['PUT', /^\/api\/shop\/einstellungen\/?$/], + ['PUT', /^\/api\/shop\/rechtstexte\/?$/], + ['POST', /^\/api\/shop\/bild\/?$/], // Oeffnungszeiten: Speichern im Namen des Kunden (Spec 2026-10-04, Abschnitt 3.4); den WhatsApp-Haken sperrt die Route selbst (403). // Nur dieser eine Pfad: die Vorschau (GET) braucht keinen Eintrag, alles andere unter /api/zeiten ist gesperrt. ['PUT', /^\/api\/zeiten\/[^/]+\/?$/], diff --git a/server/routes/abo.js b/server/routes/abo.js index 71a09cb..894c5c3 100644 --- a/server/routes/abo.js +++ b/server/routes/abo.js @@ -18,7 +18,7 @@ import { reconcilePendingSwitch, switchToWartung, } from '../services/planSwitch.js' -import { handleFeatureCheckoutEvent, isFeatureCheckoutEvent, paidFeatureOrders } from '../services/siteFeatures.js' +import { handleFeatureCheckoutEvent, isFeatureCheckoutEvent, listCustomerFeatureDocs, paidFeatureOrders } from '../services/siteFeatures.js' import { featureConfig } from '../services/featureRegistry.js' import { PRODUCT_KEYS, contractAttachmentFor, describeItems, isTermsV4OrNewer, productLabel, productView, termsDocFor } from '../services/products.js' import { @@ -68,6 +68,8 @@ function minTermEndOf(sub) { // Alte Dokumente ohne productKey sind Wartungs-Abos (Spec 6) const productKeyOf = (sub) => sub?.productKey || 'wartung' const isWartung = (sub) => Boolean(sub) && productKeyOf(sub) === 'wartung' +// Funktionen, die zum Wartungs-Abo gehoeren und mit ihm enden (portalFeatures.featureKey): Website-Analyse und Terminplaner +const ABO_FUNKTIONEN = ['analyse', 'termine'] // {{produkt}} in den Mails: Katalogname ohne "WEBklar " (F33), ohne gefundenes Abo neutral "Abo" const productLabelOf = (sub) => (sub ? productLabel(productKeyOf(sub)) : 'Abo') // {{vertragsende_paragraph}}: Paragraph zum Vertragsende in der Fassung des Kunden (v3 § 9, v4 § 11); ohne @@ -408,6 +410,24 @@ router.post('/switch', requireSession, async (req, res) => { // Stripe-Webhook // --------------------------------------------------------------------------- +/** + * Hat der Kunde dieses Abos die Website-Analyse oder den Terminplaner (Funktionsdokument in portalFeatures mit featureKey analyse + * oder termine, Zustand ausser off; ein Dokument ohne Zustand gilt wie off, wie in der Ansicht)? Nur dann gilt der Hinweis in K02 und + * K03 (Block funktionen), dass beide mit dem Wartungs-Abo enden (Entscheidung 2026-10-05). Der Kunde ist der des Abos (sub.customerId, + * wie in customerNameOf); gelesen wird wie sonst auch ueber listCustomerFeatureDocs. Wirft nie: ein Lesefehler zaehlt als ja (der Kunde + * wird eher informiert als nicht), eine Logzeile, die Mail geht in jedem Fall raus. + */ +async function hatAboFunktionen(sub) { + try { + if (!sub?.customerId) throw new Error('Abo ohne Kunden-ID') + const docs = await listCustomerFeatureDocs(sub.customerId) + return docs.some((doc) => ABO_FUNKTIONEN.includes(doc.featureKey) && (doc.state || 'off') !== 'off') + } catch (err) { + console.error(`[abo] Funktionen des Kunden nicht lesbar, der Hinweis auf das Ende von Analyse und Terminplaner bleibt in der Kündigungsbestätigung (Abo ${sub?.$id || '-'}): ${err.message}`) + return true + } +} + /** Anrede in Kundenmails: Name aus den Kundendaten des Portals, sonst der übergebene Ersatz. */ async function customerNameOf(sub, fallback = '') { const customer = sub?.customerId @@ -475,8 +495,9 @@ async function sendPortalCancellationConfirmation(sub, createdUnix) { vertragsende: fmtDate(endsAt), vertragsende_paragraph: endParagraphOf(sub), }, - // Hinweis auf nicht genutzte Arbeitszeit und auf das Ende von Website-Analyse und Terminplaner nur beim Wartungs-Abo - blocks: { wartung: isWartung(sub), funktionen: isWartung(sub) }, + // Hinweis auf nicht genutzte Arbeitszeit nur beim Wartungs-Abo; auf das Ende von Website-Analyse und Terminplaner nur beim + // Wartungs-Abo eines Kunden, der eine von beiden hat (hatAboFunktionen) + blocks: { wartung: isWartung(sub), funktionen: isWartung(sub) && (await hatAboFunktionen(sub)) }, }) await updateDocument(config.collections.subscriptions, sub.$id, { cancelRequestedAt: receivedAt.toISOString(), @@ -706,15 +727,15 @@ export function publicRateLimitState() { // Auswahl im Formular: Vertrag (Kuendigung) und Bestellung (Widerruf), Standard jeweils der erste Wert const CONTRACTS = ['abo', 'zusatzspeicher'] -const ORDERS = ['abschluss', 'wechsel', 'zusatzspeicher', 'funktion', 'termine', 'zeiten'] +const ORDERS = ['abschluss', 'wechsel', 'zusatzspeicher', 'funktion', 'termine', 'shop', 'zeiten'] // Bestellung einer Funktion -> Funktionsschluessel im Register ('funktion' meint die News-Pinnwand; jede Funktion hat ihre eigene Frist) -const FEATURE_OF_ORDER = { funktion: 'news', termine: 'termine', zeiten: 'zeiten' } +const FEATURE_OF_ORDER = { funktion: 'news', termine: 'termine', shop: 'shop', zeiten: 'zeiten' } // Bestellung -> Ereignistyp mit Widerrufsfrist (F03); beim Zusatzspeicher zaehlt nur ein Plus (openWithdrawals) const ORDER_EVENT_TYPE = { abschluss: 'abschluss', wechsel: 'wechsel', zusatzspeicher: 'speicher' } /** * Erklaerung aus dem Formular: null ohne Name oder gueltige E-Mail; { error } bei einer unzulaessigen Paketzahl. - * contract 'abo' | 'zusatzspeicher', order 'abschluss' | 'wechsel' | 'zusatzspeicher' | 'funktion' | 'termine' | 'zeiten'; packages (nur beim + * contract 'abo' | 'zusatzspeicher', order 'abschluss' | 'wechsel' | 'zusatzspeicher' | 'funktion' | 'termine' | 'shop' | 'zeiten'; packages (nur beim * Zusatzspeicher) ganze Zahl von 1 bis maxUnits, null heisst alle gebuchten (Rechtsrunde 28.09.2026). */ function validDeclaration(body) { @@ -788,6 +809,7 @@ function orderLabel(order, sub, { customer = false } = {}) { if (order === 'zusatzspeicher') return 'E-Mail-Zusatzspeicher' if (order === 'funktion') return 'Funktion (z. B. News-Pinnwand)' if (order === 'termine') return 'Terminplaner (Einrichtung)' + if (order === 'shop') return 'Webshop (Einrichtung)' if (order === 'zeiten') return 'Öffnungszeiten live' const abo = productNameOf(sub) return `${customer ? 'Abschluss Ihres Abos' : 'Abschluss des Abos'}${abo ? ` (${abo})` : ''}` @@ -948,8 +970,9 @@ router.post('/public/cancel', async (req, res) => { abo: !storage, // Hinweis auf nicht genutzte Arbeitszeit nur im Wartungs-Abo (Block innerhalb von abo) wartung: !storage && isWartung(sub), - // Website-Analyse und Terminplaner enden mit dem Wartungs-Abo (Block innerhalb von abo, nie bei der Speicher-Kündigung) - funktionen: !storage && isWartung(sub), + // Website-Analyse und Terminplaner enden mit dem Wartungs-Abo (Block innerhalb von abo, nie bei der Speicher-Kündigung), + // nur fuer einen Kunden, der eine von beiden hat (hatAboFunktionen) + funktionen: !storage && isWartung(sub) && (await hatAboFunktionen(sub)), automatisch_gekuendigt: !storage && Boolean(effectiveAt) && d.kind !== 'geschaeftsaufgabe', sonderkuendigung: !storage && Boolean(effectiveAt) && d.kind === 'geschaeftsaufgabe', ausserordentlich: !storage && !effectiveAt && d.kind === 'ausserordentlich', @@ -1097,7 +1120,7 @@ async function withdrawOrder(sub, d, receivedAt) { const FEATURE_STATE_LABEL = { setup: 'in Einrichtung', active: 'aktiv', disabled: 'abgeschaltet' } /** - * Widerruf einer Funktion (News-Pinnwand 30 € oder Terminplaner-Einrichtung 149 €, je einmalig): gehoert zu keinem Abo + * Widerruf einer Funktion (News-Pinnwand 30 €, Terminplaner-Einrichtung 149 € oder Webshop-Einrichtung 299 €, je einmalig): gehoert zu keinem Abo * und hat kein Vertragsereignis, darum aendert sich nichts automatisch. Gesucht wird der Kunde ueber die E-Mail-Adresse; * das Team bekommt in T09 die bezahlten Bestellungen der gewaehlten Funktion mit Fristende und erstattet/schaltet ab von * Hand. Jede Funktion hat ihre eigene Widerrufsfrist: gefiltert wird auf featureKey, der Anker ist deren vertragAt @@ -1142,7 +1165,7 @@ router.post('/public/withdraw', async (req, res) => { let sub = null let result = { inTime: false, deadline: null, withdrawn: [], canceled: false, cancelFailed: false, event: null, unreadable: false, startMissing: false, alreadyWithdrawn: false, late: false, openSwitchWithdrawn: false } - // Funktion (News oder Terminplaner): kein Abo, keine Vertragsereignisse; nur Nachschlagen fuer das Team (withdrawFeature) + // Funktion (News, Terminplaner oder Webshop): kein Abo, keine Vertragsereignisse; nur Nachschlagen fuer das Team (withdrawFeature) const featureKey = FEATURE_OF_ORDER[d.order] ?? null const feature = featureKey !== null const funktion = feature ? featureConfig(featureKey) : null diff --git a/server/routes/admin/analyse.js b/server/routes/admin/analyse.js index f9f2e47..b1cec8e 100644 --- a/server/routes/admin/analyse.js +++ b/server/routes/admin/analyse.js @@ -2,12 +2,13 @@ import { Router } from 'express' import { config } from '../../config.js' import { getDocument } from '../../services/appwriteAdmin.js' -import { requireAppwriteStaff } from '../../middleware/appwriteStaff.js' +import { requireAppwriteTeam } from '../../middleware/appwriteStaff.js' import { activateAnalyse, analyseEligibility, controllerView, deactivateAnalyse, ELIGIBILITY_MESSAGES, validateTextform } from '../../services/analyse.js' import { getFeatureDoc } from '../../services/siteFeatures.js' const router = Router() -router.use(requireAppwriteStaff) +// Projekt-Aktionen: jeder Mitarbeiter (nicht nur Admins) +router.use(requireAppwriteTeam) function sendError(res, err) { const status = [400, 403, 404, 409, 503].includes(err.status) && !err.type ? err.status : 500 diff --git a/server/routes/admin/funktionenAdmin.js b/server/routes/admin/funktionenAdmin.js index 4cde338..d3c8747 100644 --- a/server/routes/admin/funktionenAdmin.js +++ b/server/routes/admin/funktionenAdmin.js @@ -1,12 +1,15 @@ import { Router } from 'express' import { config } from '../../config.js' import { aboMemo } from '../../services/aboStatus.js' -import { getDocument } from '../../services/appwriteAdmin.js' +import { validateTextform } from '../../services/analyse.js' +import { getDocument, updateDocument } from '../../services/appwriteAdmin.js' import { featureConfig, featureHaken } from '../../services/featureRegistry.js' import { withLocks } from '../../services/keyedLock.js' +import { featureLegalAttachments, featureLegalPaths } from '../../services/legalDocs.js' import { FEATURE_KEYS, FEATURE_LABELS, + TEXTFORM_KEYS, adminActivate, assertOwnProject, customerOf, @@ -15,6 +18,7 @@ import { getFeatureDoc, listCustomerFeatureDocs, listCustomerFeatures, + textformFassung, ticketNachlegen, } from '../../services/siteFeatures.js' import { nachAbschaltung } from '../../services/zeiten/abschalten.js' @@ -24,8 +28,10 @@ import { normalisiereTeam } from '../../services/zeiten/pruefen.js' import { ladeZeiten, speichereTeam } from '../../services/zeiten/speicher.js' import { hauptplan, planNachKurz } from '../../services/zeiten/zeitlogik.js' import { driftWarnung, einbauanleitung, ticketStand } from '../../services/termine/einbauanleitung.js' -import { freischaltHindernis } from '../../services/termine/freischaltung.js' +import { freischaltHindernis, meldeFreigeschaltet } from '../../services/termine/freischaltung.js' import { ladeStand } from '../../services/termine/kontext.js' +import { shopTextformNoetig } from '../../services/shop/schalter.js' +import { termineTextformNoetig } from '../../services/termine/schalter.js' /** * Funktionen (News-Pinnwand, Terminplaner) je Kundenprojekt im Ticketsystem (Kunden-Reiter "Funktionen"). @@ -93,15 +99,66 @@ async function customerName(customerId) { } } +const K16_FEHLT = 'K16 mit Unterlagen NICHT gesendet – von Hand nachsenden' + +/** + * Vermerkt in der Notiz des Funktionsdokuments, dass die K16 mit den Unterlagen nicht rausging (Staff-Aktivierung in Textform, die + * Unterlagen sind der dauerhafte Datentraeger). Best effort unter der Sperre der Verwaltung: scheitert das Schreiben, steht der Fehler im + * Log, die Antwort traegt die Warnung trotzdem. + */ +async function notiereK16Fehlt(projectId, doc) { + try { + await withLocks([`feature-admin:${projectId}`], async () => { + const frisch = await getDocument(config.collections.portalFeatures, doc.$id) + await updateDocument(config.collections.portalFeatures, doc.$id, { + adminNote: composeAdminNote(frisch.adminNote, { by: 'System', action: K16_FEHLT }), + }) + }) + } catch (err) { + console.error('[funktionen-admin] Notiz zur nicht gesendeten K16 nicht geschrieben:', err.message) + } +} + const ticketExists = async (ticketId) => (await readTicket(ticketId)) !== null +/** Verlangt die Aktivierung dieser Funktion fuer diese Adresse die Zustimmung in Textform? Nur Terminplaner und Webshop, je nach Modus und Pilotliste der Funktion. */ +const textformVerlangt = { termine: termineTextformNoetig, shop: shopTextformNoetig } + +/** + * Zustimmung des Kunden in Textform fuer die Aktivierung des Terminplaners oder des Webshops durch das Team (F13a; AVV Webshop, + * Vorbemerkung Abs. 2). null, wenn sie entfaellt: andere Funktion, Modus off oder pilot, Pilotadresse (termineTextformNoetig, + * shopTextformNoetig) oder ein Dokument, das nichts mehr aktiviert und dem nichts fehlt (schon aktiv, oder durch Kauf freigeschaltet, + * dann liegt die Zustimmung des Checkouts vor). Sonst { date, channel }; fehlt oder ist eine Angabe ungueltig (Datum vor der Fassung + * oder in der Zukunft, kein Weg), 400 - ohne dass etwas angelegt wird. + */ +async function textformFuerAktivierung({ body, customerId, key, doc }) { + if (!TEXTFORM_KEYS.includes(key)) return null + if (doc?.state === 'active' || doc?.unlockedByPurchase === true) return null + const kunde = await customerOf(customerId) + if (!textformVerlangt[key](kunde?.email)) return null + // nur Texte: validateTextform macht aus jedem Wert einen Text (aus ['chat'] wuerde 'chat') + const text = (wert) => (typeof wert === 'string' ? wert : '') + return validateTextform({ date: text(body?.textformDate), channel: text(body?.textformChannel) }, { minDate: textformFassung(key) }) +} + +/** Leistungsbeschreibung und AVV der Funktion als PDF fuer die K16 der Textform-Aktivierung; nicht lesbar => 503, bevor etwas geaendert wird. */ +async function unterlagenAlsAnhang(key) { + try { + return await featureLegalAttachments(key, { ohne: ['widerruf'] }) + } catch (err) { + console.error('[funktionen-admin] Rechtsunterlagen (PDF) fuer die Freischaltung nicht lesbar:', err.message) + const funktion = key === 'shop' ? 'Der Webshop' : 'Der Terminplaner' + throw Object.assign(new Error(`Die Rechtsunterlagen (PDF) sind nicht lesbar. ${funktion} wurde nicht freigeschaltet. Bitte später erneut versuchen.`), { status: 503 }) + } +} + // Die Funktionsdokumente des Kunden werden einmal gelesen und fuer alle Projekte und Funktionen verwendet. // hindernis (freischaltHindernis, termine/freischaltung.js): was die Freischaltung eines Terminplaners in setup noch hindert // ('kein_abo' | 'keine_einstellung' | 'abo_unbekannt'), null = nichts; ausserhalb von setup und bei News immer null. Das Abo wird // je Anfrage einmal gelesen (ein gemeinsamer aboMemo fuer alle Projekte des Kunden), portalFeatures nicht erneut. async function adminView(customerId) { const docs = await listCustomerFeatureDocs(customerId) - const projects = await listCustomerFeatures(customerId, { docs }) + const projects = await listCustomerFeatures(customerId, { docs, includeArchived: true }) const abo = aboMemo() for (const p of projects) { for (const key of FEATURE_KEYS) { @@ -116,9 +173,36 @@ async function adminView(customerId) { return projects } +/** + * Angaben des Terminplaners fuer die Karte im WOMS (F13a): textformNoetig = verlangt die Aktivierung dieses Kunden die Zustimmung in + * Textform (Modus on und Adresse nicht in der Pilotliste), avvVersion = Fassung der Unterlagen, auf die sich die Zustimmung bezieht, + * links = absolute, versionierte Adressen von Leistungsbeschreibung und AVV fuer die Chat-Vorlage (nie Adressen ohne Fassung). + */ +async function termineFeld(customerId) { + const kunde = await customerOf(customerId) + const { lb, avv } = featureLegalPaths('termine') + return { + textformNoetig: termineTextformNoetig(kunde?.email), + avvVersion: config.termine.avvVersion, + links: { lb: `${config.publicBaseUrl}${lb}`, avv: `${config.publicBaseUrl}${avv}` }, + } +} + +/** Dasselbe fuer den Webshop (AVV Webshop, Vorbemerkung Abs. 2): Fassung = config.shop.fassung, Adressen der versionierten Unterlagen des Webshops. */ +async function shopFeld(customerId) { + const kunde = await customerOf(customerId) + const { lb, avv } = featureLegalPaths('shop') + return { + textformNoetig: shopTextformNoetig(kunde?.email), + avvVersion: config.shop.fassung, + links: { lb: `${config.publicBaseUrl}${lb}`, avv: `${config.publicBaseUrl}${avv}` }, + } +} + funktionenAdminRouter.get('/:customerId', async (req, res) => { try { - res.json({ projects: await adminView(req.params.customerId) }) + const projects = await adminView(req.params.customerId) + res.json({ projects, termine: await termineFeld(req.params.customerId), shop: await shopFeld(req.params.customerId) }) } catch (err) { sendError(res, err) } @@ -258,6 +342,7 @@ funktionenAdminRouter.post('/:customerId/:key', async (req, res) => { } await assertOwnProject(customerId, projectId) const by = adminName(req) + let freigeschaltet = null // Terminplaner oder Webshop mit Zustimmung in Textform: K16 mit den Unterlagen nach der Sperre let ersatzTicket if (key === 'zeiten' && action === 'activate') { await zeitenAktivieren({ customerId, projectId, by, note }) @@ -268,7 +353,10 @@ funktionenAdminRouter.post('/:customerId/:key', async (req, res) => { const doc = await getFeatureDoc(customerId, projectId, key) const adminNote = composeAdminNote(doc?.adminNote, { by, action, note }) if (action === 'activate') { - await adminActivate(customerId, projectId, key, { by, adminNote }) + const textform = await textformFuerAktivierung({ body: req.body, customerId, key, doc }) + const attachments = textform ? await unterlagenAlsAnhang(key) : [] + const aktiviert = await adminActivate(customerId, projectId, key, { by, adminNote, textform }) + if (textform) freigeschaltet = { doc: aktiviert, attachments } } else if (action === 'abnahme') { await freischaltenNachAbnahme({ customerId, projectId, key, by, adminNote }) } else if (action === 'ticket') { @@ -281,10 +369,18 @@ funktionenAdminRouter.post('/:customerId/:key', async (req, res) => { } }) } + // Der Kunde bekommt die Unterlagen auf einem dauerhaften Datentraeger (Anhang); ein Versandfehler wird dort geloggt, nie geworfen. + // Ging K16 nicht raus (Versand, Kunde ohne Adresse), steht es in der Notiz und die Antwort traegt warnung: true - das Team sendet von Hand nach + let warnung = false + if (freigeschaltet && !(await meldeFreigeschaltet(freigeschaltet.doc, { attachments: freigeschaltet.attachments }))) { + warnung = true + await notiereK16Fehlt(projectId, freigeschaltet.doc) + } res.json({ projects: (await adminView(customerId)).filter((p) => p.projectId === projectId), // Ergebnis des Ersatz-Tickets beim Abschalten der Zeiten (angelegt | aktuell | fehler), damit ein Fehler im Panel sichtbar wird ...(ersatzTicket ? { ersatzTicket } : {}), + ...(warnung ? { warnung } : {}), }) } catch (err) { sendError(res, err) diff --git a/server/routes/admin/gitea.js b/server/routes/admin/gitea.js index efecce8..b84bb57 100644 --- a/server/routes/admin/gitea.js +++ b/server/routes/admin/gitea.js @@ -1,10 +1,13 @@ import { Router } from 'express' +import { config } from '../../config.js' +import { listDocuments, Query } from '../../services/appwriteAdmin.js' import { syncReposToAppwrite, fetchRepoReadme, importNewRepos } from '../../services/giteaAdmin.js' -import { requireAppwriteStaff } from '../../middleware/appwriteStaff.js' +import { requireAppwriteTeam } from '../../middleware/appwriteStaff.js' const router = Router() -router.use(requireAppwriteStaff) +// Projekt-Aktionen: jeder Mitarbeiter (nicht nur Admins) +router.use(requireAppwriteTeam) router.post('/sync-repos', async (_req, res) => { try { @@ -35,6 +38,12 @@ router.get('/repo-readme', async (req, res) => { } try { + // Nur Repos, die ein Projekt sind - der System-Schluessel sieht sonst jedes (auch private) Repo + const known = await listDocuments(config.collections.websiteProjects, [ + Query.equal('repoFullName', repoFullName), + Query.limit(1), + ]) + if (!known[0]) return res.status(404).json({ error: 'Projekt nicht gefunden' }) const readme = await fetchRepoReadme(repoFullName) if (!readme) { return res.json({ ok: true, found: false, repoFullName }) diff --git a/server/routes/admin/preview.js b/server/routes/admin/preview.js index 3f135f5..ded1461 100644 --- a/server/routes/admin/preview.js +++ b/server/routes/admin/preview.js @@ -2,7 +2,7 @@ import { Router } from 'express' import { config } from '../../config.js' import { getDocument, updateDocument } from '../../services/appwriteAdmin.js' -import { requireAppwriteStaff } from '../../middleware/appwriteStaff.js' +import { requireAppwriteTeam } from '../../middleware/appwriteStaff.js' import { envKey, encryptEnv, decryptEnv, envNames, validateEnvChange } from '../../services/previewEnv.js' import { listRunLogs, readRunLog, isRunFile } from '../../services/previewLogs.js' import * as runnerClient from '../../services/previewRunnerClient.js' @@ -11,7 +11,8 @@ import { withLocks } from '../../services/keyedLock.js' import { repoHasDevScript } from '../../services/previewDevScript.js' const router = Router() -router.use(requireAppwriteStaff) +// Projekt-Aktionen: jeder Mitarbeiter (nicht nur Admins) +router.use(requireAppwriteTeam) const BUSY = 'Server ausgelastet, versuche es gleich noch mal' const MODES = ['auto', 'build', 'dev', 'off'] diff --git a/server/routes/admin/services.js b/server/routes/admin/services.js index b9dc194..21bdcc7 100644 --- a/server/routes/admin/services.js +++ b/server/routes/admin/services.js @@ -2,11 +2,12 @@ import { Router } from 'express' import { config } from '../../config.js' import { listDocuments, Query } from '../../services/appwriteAdmin.js' import { servicesCatalog } from '../../services/projectKinds.js' -import { requireAppwriteStaff } from '../../middleware/appwriteStaff.js' +import { requireAppwriteTeam } from '../../middleware/appwriteStaff.js' const router = Router() -router.use(requireAppwriteStaff) +// Projekt-Aktionen: jeder Mitarbeiter (nicht nur Admins) +router.use(requireAppwriteTeam) const CHUNK = 100 diff --git a/server/routes/admin/websiteProjects.js b/server/routes/admin/websiteProjects.js index a22bb46..1930d35 100644 --- a/server/routes/admin/websiteProjects.js +++ b/server/routes/admin/websiteProjects.js @@ -6,14 +6,15 @@ import { } from '../../services/giteaAdmin.js' import { assignProject, reconcileProject } from '../../services/projectAssignment.js' import { completeAssignment } from '../../services/projectKindAssign.js' -import { requireAppwriteStaff } from '../../middleware/appwriteStaff.js' +import { requireAppwriteTeam } from '../../middleware/appwriteStaff.js' const router = Router() -router.use(requireAppwriteStaff) +// Projekt-Aktionen: jeder Mitarbeiter (nicht nur Admins) +router.use(requireAppwriteTeam) router.post('/create-from-template', async (req, res) => { - const { repoName, displayName, subdomain, customerId = '', ticketId = '', owner } = req.body || {} + const { repoName, displayName, subdomain, customerId = '', ticketId = '', owner, kind = '' } = req.body || {} if (!repoName && !displayName) { return res.status(400).json({ error: 'repoName oder displayName ist erforderlich' }) @@ -27,6 +28,8 @@ router.post('/create-from-template', async (req, res) => { customerId, ticketId, owner, + kind, + staffName: req.appwriteUser?.name || req.appwriteUser?.email || '', }) return res.status(201).json({ ok: true, ...result }) } catch (err) { @@ -38,6 +41,10 @@ router.post('/create-from-template', async (req, res) => { // Projekt bearbeiten: Name / Subdomain / Typ -> synchronisiert Git + Preview + Appwrite router.patch('/:id', async (req, res) => { const { projectName, subdomain, projectType } = req.body || {} + // Vorlagen bleiben Admin-Sache (im Ticketsystem nur fuer Admins sichtbar) + if (projectType === 'template' && !req.appwriteUser?.labels?.includes('admin')) { + return res.status(403).json({ error: 'Nur Admins können Vorlagen anlegen.' }) + } try { const result = await updateWebsiteProject(req.params.id, { projectName, subdomain, projectType }) return res.json({ ok: true, ...result }) diff --git a/server/routes/auth.js b/server/routes/auth.js index 578dcf4..6ab0708 100644 --- a/server/routes/auth.js +++ b/server/routes/auth.js @@ -292,7 +292,8 @@ router.get('/preview-access', async (req, res) => { const projects = await listDocuments(config.collections.websiteProjects, queries) const project = projects[0] - if (!project) { + // Archiviert: fuer Kunden wie kein Projekt (das Portal blendet es aus) + if (!project || (project.archived && !admin)) { return res.json({ allowed: false, reason: 'no_project' }) } if (!isPreviewProjectReady(project)) { diff --git a/server/routes/funktionen.js b/server/routes/funktionen.js index a8a2cf9..b122bc4 100644 --- a/server/routes/funktionen.js +++ b/server/routes/funktionen.js @@ -19,6 +19,7 @@ import { } from '../services/siteFeatures.js' import { actingPerson, speichereNachweisAusAnfrage, zustimmungFuerSitzung } from '../services/featureConsents.js' import { termineFuerKunde } from '../services/termine/schalter.js' +import { shopFuerKunde } from '../services/shop/schalter.js' import { zeitenFuerKunde } from '../services/zeiten/schalter.js' import { einschaltenDeps, einschaltenZeiten, zeitenProjektAnsicht } from '../services/zeiten/einschalten.js' import { starteBehalten } from '../services/zeiten/behalten.js' @@ -75,6 +76,7 @@ async function analyseView(customerId, projectId) { avvVersion: config.analyse.avvVersion, legal: legalPaths(config.analyse.avvVersion), controller, + activatedAt: doc?.state === 'active' ? doc.activatedAt || null : null, } } @@ -233,6 +235,9 @@ router.get('/', requireSession, async (req, res) => { // Terminplaner: Schalter TERMINE_ENABLED (off | pilot | on); nur wenn verfuegbar zeigt das Portal die Kachel, dann auch // mit dem Abo des Kunden (bestellbar, grund) und dem Preis des Registers termine: await termineAngebot(req.session.email, customerId, abos), + // Webshop: Schalter SHOP_ENABLED (off | pilot | on); nur wenn verfuegbar zeigt das Portal die Kachel; Anfrage und Zustand je Projekt + // kommen aus projects[].features.shop und GET /api/shop/status + shop: { verfuegbar: shopFuerKunde(req.session.email) }, // Oeffnungszeiten live: Schalter ZEITEN_ENABLED (off | pilot | on); nur wenn verfuegbar zeigt das Portal die Kachel, dann mit // dem Abo-Stand (inklusive, grund), dem Preis und den Texten des Gratisweges "Einschalten" zeiten: await zeitenAngebot(req.session.email, customerId, abos), @@ -304,7 +309,7 @@ router.post('/:key/checkout', requireSession, async (req, res) => { return res.status(400).json({ error: 'Bitte wähle ein Projekt.' }) } const customerId = getSessionCustomerId(req) - await assertOwnProject(customerId, projectId) + await assertOwnProject(customerId, projectId, { forNewOrder: true }) if (fortsetzen && (await getFeatureDoc(customerId, projectId, key))?.state !== 'ordered') { throw bestellFehler(400, ZUSTIMMUNG_NOETIG, 'zustimmung_fehlt') } @@ -365,7 +370,7 @@ router.post('/:key/checkout', requireSession, async (req, res) => { projectId, key, sessionId: created.id, - consent: { text: hakenTexte(feature), at: klickAt, ip: req.ip, userAgent: req.get('user-agent') }, + consent: { text: hakenTexte(feature), at: klickAt }, }) return created }) @@ -468,6 +473,7 @@ router.post('/analyse/activate', analyseGuard, async (req, res) => { return res.status(403).json({ error: 'In der Kundenansicht kann nichts geändert werden.' }) } const { projectId, accentColor, consent } = req.body || {} + await assertOwnProject(getSessionCustomerId(req), analyseProjectId(projectId), { forNewOrder: true }) const { doc } = await activateAnalyse({ customerId: getSessionCustomerId(req), projectId: analyseProjectId(projectId), diff --git a/server/routes/projects.js b/server/routes/projects.js index 093d24a..70697e6 100644 --- a/server/routes/projects.js +++ b/server/routes/projects.js @@ -14,7 +14,8 @@ const router = Router() async function loadOwnProject(req, res, next) { const customerId = getSessionCustomerId(req) const project = await getDocument(config.collections.websiteProjects, req.params.id).catch(() => null) - if (!project || !customerId || project.customerId !== customerId) { + // Archivierte Projekte sieht der Kunde nicht mehr (wie in der Liste) + if (!project || !customerId || project.customerId !== customerId || project.archived) { return res.status(404).json({ error: 'Projekt nicht gefunden' }) } req.project = project @@ -31,9 +32,11 @@ router.get('/', requireSession, async (req, res) => { const projects = await listDocuments(config.collections.websiteProjects, [ Query.equal('customerId', customerId), Query.orderDesc('$createdAt'), + Query.limit(500), // ohne Limit liefert Appwrite nur 25 ]) - const sanitized = projects.map((p) => ({ + // Archivierte Projekte (Vorschau entfernt) blendet das Portal aus (Entscheidung 2026-10-04) + const sanitized = projects.filter((p) => !p.archived).map((p) => ({ id: p.$id, projectName: p.projectName || '', subdomain: p.subdomain || '', diff --git a/server/routes/publicShop.js b/server/routes/publicShop.js new file mode 100644 index 0000000..5d788b9 --- /dev/null +++ b/server/routes/publicShop.js @@ -0,0 +1,673 @@ +import express, { Router } from 'express' +import { config } from '../config.js' +import { analyseDomains } from '../services/analyse.js' +import { getDocument } from '../services/appwriteAdmin.js' +import { bildMeta, bildPfad } from '../services/shop/bilder.js' +import { abschlussAusstehend, bestellungLesen, ipHashVon, legeBestellungAn, pruefeRueckUrl, reserviert, schliesseAb, tokenPasst } from '../services/shop/bestellungen.js' +import { sessionHolen } from '../services/shop/connect.js' +import { istGeoeffnet, oeffentlicheRechtstexte, shopPerSiteKey } from '../services/shop/kontext.js' +import { euro } from '../services/shop/geld.js' +import { berechne } from '../services/shop/preise.js' +import { listeProdukte, parseProdukt, produktOeffentlich } from '../services/shop/produkte.js' +import { GEWAEHRLEISTUNG_BILD, gewaehrleistungsHinweis, versandUndZahlung, zahlungsartenText } from '../services/shop/rechtstexte.js' +import { shopFuerKunde, shopModus } from '../services/shop/schalter.js' +import { formToken, pruefeFormToken } from '../services/shop/token.js' +import { eingangText, erfasseWiderruf } from '../services/shop/widerrufe.js' +import { darfAnfrage, ipSchluessel, zaehle } from '../services/termine/limits.js' + +/** + * Oeffentliche Schnittstelle des Webshops: /api/public/shop/v1 (Plan 2026-10-05-webshop.md, Block F; Muster + * routes/publicTermine.js). Ohne Session, ohne Captcha. Hier steht das Lesen (F1: Konfiguration samt Artikeln, Artikelbilder); + * Bestellung, Status und Widerruf (F2/F3) kommen als POST/GET-Routen in dieselbe Datei und nutzen route(), schreibe() und die + * Fehlerhelfer. + * + * - Der Router bringt seinen eigenen Parser (16 KB) und seine Fehlerbehandlung mit und haengt in server/index.js VOR dem + * globalen express.json (2 MB). Antworten sind immer JSON { ... } oder { fehler: { : Text }, code? }; Rohtexte von + * Fehlern verlassen den Server nie (unbekannter Fehler => 503 "Gerade nicht erreichbar"). + * - Antworten enthalten nie customerId, projectId, featureId, $id, Stripe-IDs oder E-Mail-Adressen ausser der oeffentlichen + * Anbieter-Adresse des Haendlers. + * - Unbekannter siteKey oder eine Funktion, die nicht active oder disabled ist: immer 200 { aktiv: false } (kein Orakel). Bestellen, + * Pruefen, Status und Bilder zusaetzlich nur bei Modus on bzw. Pilotmodus mit Besitzer auf der Pilotliste. Eine abgeschaltete + * Funktion (disabled), Modus off und ein Besitzer ausserhalb der Pilotliste antworten beim Lesen mit + * { aktiv: true, offen: false, nurWiderruf: true, ... }, und POST /widerruf (samt Preflight) geht weiter: die Widerrufsfunktion + * ist gesetzliche Pflicht (§ 356a BGB), solange es Bestellungen geben kann. + * - CORS: GET fuer alle (*). POST und Preflight nur von Seiten des Betriebs (analyseDomains des Projekts), des Portals + * (config.publicBaseUrl) und gleicher Herkunft (Host-Header), Origin in kanonischer Schreibweise; sonst 403. Meldungen ohne + * Daten (429, 503, aktiv:false) tragen `*`, bei geprueftem POST die Origin. + * - Grenzen: je IP 120 je Minute (darfAnfrage), je Shop 600 je Minute (Schluessel 'shop:'); die Artikelbilder haben + * eigene, groessere Zaehler, damit eine Seite mit vielen Bildern die Konfiguration nicht aussperrt. + */ + +const router = Router() + +const BODY_MAX = 16 * 1024 +const MINUTE_MS = 60 * 1000 +const SHOP_JE_MINUTE = 600 +const BILD_JE_IP = 600 +const BILD_JE_SHOP = 6000 +const BILD_RE = /^([a-z0-9-]{1,64})-(s|l)\.webp$/ +const MIN_ANTWORT_STANDARD_MS = 300 +const LAENDER_NAMEN = { DE: 'Deutschland', AT: 'Österreich' } +const ZAHLUNGSARTEN_REIHENFOLGE = ['card', 'klarna', 'sepa_debit'] + +const NICHT_ERREICHBAR = { fehler: { _: 'Gerade nicht erreichbar' } } +const NICHT_GEFUNDEN = { fehler: { _: 'Nicht gefunden' } } +const ZU_GROSS = { fehler: { _: 'Die Anfrage ist zu groß.' } } +const UNGUELTIG = { fehler: { _: 'Ungültige Anfrage.' } } +const MELDUNG_ZU_VIELE = 'Zu viele Anfragen. Bitte versuchen Sie es später noch einmal.' + +// Uhr der Schnittstelle: Tests setzen eine feste Uhr (die Dienste nehmen `now` immer als Parameter). +let uhr = () => new Date() +export function setzeUhrFuerTests(neueUhr) { + uhr = typeof neueUhr === 'function' ? neueUhr : () => new Date() +} + +/** Besitzer-Adressen fuer den Pilotmodus je ctx (ein ctx aus shopPerSiteKey lebt 30 Sekunden): ctx -> Promise. */ +const besitzer = new WeakMap() +export function resetPublicShopForTests() { + uhr = () => new Date() +} + +// ---------------------------------------------------------------- Antworten und Fehler + +/** Fehler mit Statuscode fuer den Besucher (4xx); `fehlerErgebnis` macht daraus die Antwort. */ +const fehlerMit = (status, fehler, extra = {}) => Object.assign(new Error(Object.values(fehler)[0]), { status, fehler, ...extra }) +const zuViele = (retryAfterS) => fehlerMit(429, { _: MELDUNG_ZU_VIELE }, { retryAfter: retryAfterS }) +const nichtGefunden = () => fehlerMit(404, NICHT_GEFUNDEN.fehler) + +const istObjekt = (wert) => wert !== null && typeof wert === 'object' && !Array.isArray(wert) +const nurTexte = (wert) => (istObjekt(wert) ? Object.fromEntries(Object.entries(wert).filter(([, t]) => typeof t === 'string')) : {}) + +/** Antwort fuer einen Fehler: Fehler des Besuchers (4xx mit `fehler`) wie sie sind, alles andere 503 ohne Rohtext. */ +function fehlerErgebnis(err) { + const status = err?.status + const fehler = nurTexte(err?.fehler) + if (Number.isInteger(status) && status >= 400 && status < 500 && Object.keys(fehler).length > 0) { + const body = { fehler } + if (typeof err.code === 'string') body.code = err.code + if (typeof err.produktId === 'string') body.produktId = err.produktId + return { status, body, headers: Number.isInteger(err.retryAfter) ? { 'Retry-After': String(err.retryAfter) } : {} } + } + console.error(`[publicShop] ${err?.name ?? 'Fehler'}: ${String(err?.message ?? '').slice(0, 200)}`) + return { status: 503, body: NICHT_ERREICHBAR } +} + +/** + * Schreibt eine Antwort { status, body, typ, cache, headers }; ohne ETag, immer mit nosniff. cors = { erlaubt: Origin|null } + * (fuer POST, F2/F3). GET und HEAD tragen immer `*`; Meldungen ohne Daten (429, 503, aktiv:false) auch bei POST. + */ +function schreibe(req, res, ergebnis, cors = { erlaubt: null }) { + const { status = 200, body, typ = 'application/json; charset=utf-8', cache: cacheControl = 'no-store', headers = {} } = ergebnis + const lesen = req.method === 'GET' || req.method === 'HEAD' + if (lesen) res.set('Access-Control-Allow-Origin', '*') + else if (cors.erlaubt) res.set({ 'Access-Control-Allow-Origin': cors.erlaubt, Vary: 'Origin' }) + else if (status === 429 || status === 503 || body?.aktiv === false) res.set('Access-Control-Allow-Origin', '*') + res.status(status) + res.set({ 'Cache-Control': cacheControl, 'X-Content-Type-Options': 'nosniff', ...headers }) + if (status === 204) return res.end() + const inhalt = typeof body === 'string' ? body : JSON.stringify(body) + res.set({ 'Content-Type': typ, 'Content-Length': String(Buffer.byteLength(inhalt)) }) + return res.end(inhalt) +} + +/** Sendet eine Datei (Ergebnis { datei: absoluter Pfad }); fehlt sie, antwortet 404 wie sonst. */ +function sendeDatei(req, res, ergebnis) { + return new Promise((resolve) => { + res.set({ 'Access-Control-Allow-Origin': '*', 'Cross-Origin-Resource-Policy': 'cross-origin' }) + res.sendFile( + ergebnis.datei, + { cacheControl: false, dotfiles: 'deny', headers: { 'Cache-Control': ergebnis.cache, 'X-Content-Type-Options': 'nosniff' } }, + (err) => { + if (err && !res.headersSent) { + for (const kopf of ['Cache-Control', 'Content-Type', 'Content-Length', 'Cross-Origin-Resource-Policy']) res.removeHeader(kopf) + schreibe(req, res, { status: 404, body: NICHT_GEFUNDEN }) + } else if (err) res.destroy() + resolve() + }, + ) + }) +} + +/** Letzte Absicherung, wenn schon das Schreiben der Antwort scheitert: schlichtes 503, sofern noch nichts gesendet wurde. */ +function notantwort(res, err) { + try { + console.error(`[publicShop] Antwort nicht geschrieben: ${err?.name ?? 'Fehler'}: ${String(err?.message ?? '').slice(0, 200)}`) + } catch { + // das Log darf die Notantwort nicht verhindern + } + try { + if (res.headersSent) return void res.end() + const inhalt = JSON.stringify(NICHT_ERREICHBAR) + res.statusCode = 503 + res.setHeader('Content-Type', 'application/json; charset=utf-8') + res.setHeader('Cache-Control', 'no-store') + res.setHeader('Content-Length', String(inhalt.length)) // nur ASCII + return void res.end(inhalt) + } catch { + try { + res.destroy() + } catch { + // nichts mehr zu tun + } + } +} + +function schreibeSicher(req, res, ergebnis, cors) { + try { + schreibe(req, res, ergebnis, cors) + } catch (err) { + notantwort(res, err) + } +} + +/** { aktiv: false }: dieselbe Antwort fuer unbekannte, inaktive und abgeschaltete Shops (GET kurz im Browser-Cache). */ +const aktivFalse = (req) => ({ body: { aktiv: false }, cache: req.method === 'GET' ? 'public, max-age=15' : 'no-store' }) + +const warte = (ms) => new Promise((resolve) => setTimeout(resolve, ms)) +const minAntwortMs = () => (Number.isFinite(config.shop?.minAntwortMs) && config.shop.minAntwortMs >= 0 ? config.shop.minAntwortMs : MIN_ANTWORT_STANDARD_MS) + +/** + * Handler mit Fehlerabbildung und optionaler Mindestantwortzeit. fn(req, now, cors) liefert ein Ergebnis fuer schreibe + * (oder { datei, cache } fuer sendeDatei) oder wirft; cors.erlaubt setzt fn, sobald die Herkunft geprueft ist (F2/F3). + * Mit mindestdauer antwortet auch ein Fehler nicht schneller als minAntwortMs (ab Beginn der Anfrage). + */ +const route = (fn, { mindestdauer = false } = {}) => async (req, res) => { + try { + const cors = { erlaubt: null } + let ergebnis + try { + ergebnis = await fn(req, uhr(), cors) + } catch (err) { + ergebnis = fehlerErgebnis(err) + } + if (mindestdauer) { + const rest = minAntwortMs() - (performance.now() - req.shopStart) + if (rest > 0) await warte(rest) + } + if (ergebnis.datei) await sendeDatei(req, res, ergebnis) + else schreibe(req, res, ergebnis, cors) + } catch (err) { + notantwort(res, err) + } +} + +// ---------------------------------------------------------------- Beginn: Zeit, Rumpf + +router.use((req, res, next) => { + req.shopStart = performance.now() + // Zu grosse Rumpfe gar nicht erst lesen; die Verbindung wird danach geschlossen (der Rumpf bleibt ungelesen). + if (Number(req.get('content-length')) > BODY_MAX) { + res.set('Connection', 'close') + return schreibeSicher(req, res, { status: 413, body: ZU_GROSS }) + } + return next() +}) +router.use(express.json({ limit: BODY_MAX })) + +// ---------------------------------------------------------------- Zugang + +/** E-Mail-Adresse des Shop-Besitzers (leer, wenn der Kunde fehlt); je ctx einmal geladen. */ +function besitzerEmail(ctx) { + let gemerkt = besitzer.get(ctx) + if (!gemerkt) { + gemerkt = getDocument(config.collections.customers, ctx.shop.customerId).then( + (kunde) => String(kunde?.email ?? ''), + (err) => { + if (err?.status === 404) return '' + besitzer.delete(ctx) // Fehler nicht merken + throw err + }, + ) + besitzer.set(ctx, gemerkt) + } + return gemerkt +} + +/** + * Der Kontext des Shops, wenn er ueber die Schnittstelle erreichbar ist; sonst null. Modus off => null ohne jede Abfrage; + * im Pilotmodus muss der Besitzer auf der Pilotliste stehen (SHOP_PILOT_EMAILS). + */ +async function erreichbarerShop(siteKey, now) { + const modus = shopModus() + if (modus === 'off') return null + const ctx = await shopPerSiteKey(siteKey, { now }) + if (!ctx) return null + if (modus === 'pilot' && !shopFuerKunde(await besitzerEmail(ctx))) return null + return ctx +} + +/** + * Zugang fuer die Widerrufsfunktion (§ 356a BGB): sie bleibt erreichbar, solange die Funktion active oder disabled ist, auch bei + * SHOP_ENABLED=off und wenn der Besitzer nicht (mehr) zum Pilotkreis gehoert; die gesetzliche Pflicht laeuft mit den Bestellungen + * weiter. Bestellen bleibt gesperrt (`bestellbar`). Bei off wird die Pilotliste gar nicht erst gelesen. + * @returns {Promise<{ ctx: object|null, bestellbar: boolean }>} + */ +async function widerrufZugang(siteKey, now) { + const ctx = await shopPerSiteKey(siteKey, { now }) + if (!ctx) return { ctx: null, bestellbar: false } + const modus = shopModus() + const bestellbar = modus === 'on' || (modus === 'pilot' && shopFuerKunde(await besitzerEmail(ctx))) + return { ctx, bestellbar } +} + +// ---------------------------------------------------------------- Konfiguration + +const text = (wert) => (typeof wert === 'string' ? wert.trim() : '') +const basisUrl = () => String(config.publicBaseUrl ?? '').replace(/\/+$/, '') + +function anbieterOeffentlich(a) { + return { name: a.name, strasse: a.strasse, plz: a.plz, ort: a.ort, land: a.land, email: a.email, telefon: a.telefon } +} + +/** Kurzer Versandtext, z. B. 'Versand nach Deutschland 4,90 €, ab 50,00 € versandkostenfrei'. */ +function versandText(e) { + if (!e.versand.aktiv) return e.abholung.aktiv ? 'Nur Abholung' : '' + const laender = (Array.isArray(e.versand.laender) ? e.versand.laender : []).map((code) => { + const name = LAENDER_NAMEN[code] ?? code + const kosten = e.versand.kostenCent?.[code] + return Number.isInteger(kosten) ? `${name} ${euro(kosten)}` : name + }) + let t = laender.length > 0 ? `Versand nach ${laender.join(', ')}` : 'Versand' + if (Number.isInteger(e.versand.freiAbCent)) t += `, ab ${euro(e.versand.freiAbCent)} versandkostenfrei` + return t +} + +function steuerHinweisKurz(e) { + return e.kleinunternehmer ? 'Gemäß § 19 UStG wird keine Umsatzsteuer berechnet.' : 'Alle Preise inkl. MwSt.' +} + +/** Der Teil `shop` der Antwort (Anzeige- und Kasseninformationen des Haendlers). */ +function shopOeffentlich(e) { + const arten = Array.isArray(e.zahlungsarten) ? e.zahlungsarten : [] + return { + name: e.shopname, + akzent: e.akzent, + anbieter: anbieterOeffentlich(e.anbieter), + lieferzeit: e.lieferzeit, + lieferlaender: e.versand.aktiv ? (Array.isArray(e.versand.laender) ? e.versand.laender : []).map((code) => ({ code, name: LAENDER_NAMEN[code] ?? code })) : [], + abholung: { aktiv: e.abholung.aktiv === true, adresse: e.abholung.adresse, hinweis: e.abholung.hinweis }, + zahlungsarten: zahlungsartenText(e), + zahlungsartenListe: ZAHLUNGSARTEN_REIHENFOLGE.filter((a) => arten.includes(a)).map((a) => zahlungsartenText({ zahlungsarten: [a] })), + versandText: versandText(e), + mindestbestellText: Number.isInteger(e.mindestbestellCent) ? `Mindestbestellwert ${euro(e.mindestbestellCent)}` : '', + kleinunternehmer: e.kleinunternehmer === true, + steuerHinweisKurz: steuerHinweisKurz(e), + } +} + +/** Die bestaetigten Rechtstexte (Fassung, die Kaeufer sehen) oder null, solange keine Fassung bestaetigt ist. */ +function rechtstexteOeffentlich(ctx) { + const t = oeffentlicheRechtstexte(ctx) + if (!t) return null + return { + agb: t.agb, + widerrufsbelehrung: t.widerrufsbelehrung, + widerrufsformular: t.widerrufsformular, + versandUndZahlung: versandUndZahlung(ctx.shop.einstellungen), + impressumUrl: t.impressumUrl, + datenschutzUrl: t.datenschutzUrl, + } +} + +router.get( + '/:siteKey', + route(async (req, now) => { + const { siteKey } = req.params + if (!darfAnfrage(req.ip, now)) throw zuViele(60) + const { ctx, bestellbar } = await widerrufZugang(siteKey, now) + if (!ctx) return aktivFalse(req) + if (!zaehle(`shop:${siteKey}`, SHOP_JE_MINUTE, MINUTE_MS, now)) throw zuViele(60) + + const e = ctx.shop.einstellungen + const widerrufUrl = `${basisUrl()}/shop/${siteKey}/widerruf` + const token = formToken(siteKey, now) + + // Abgeschaltet (Funktion disabled, SHOP_ENABLED=off oder ausserhalb des Pilotkreises): nichts mehr zu kaufen, aber der Widerruf bleibt erreichbar + if (ctx.doc.state === 'disabled' || !bestellbar) { + return { + body: { + aktiv: true, + offen: false, + nurWiderruf: true, + shop: { name: e.shopname, akzent: e.akzent, anbieter: anbieterOeffentlich(e.anbieter) }, + widerrufUrl, + formToken: token, + }, + } + } + + const dokumente = await listeProdukte(ctx.shop.id) + const [offen, belegt] = await Promise.all([istGeoeffnet(ctx, { produkte: dokumente }), reserviert(ctx.shop.id, now)]) + const produkte = [] + for (const doc of dokumente) { + if (doc.aktiv === false) continue + const p = produktOeffentlich(doc, { einstellungen: e, reserviert: belegt, siteKey }) + if (p) produkte.push(p) + } + const kategorien = [...new Set(produkte.map((p) => text(p.kategorie)).filter(Boolean))] + return { + body: { + aktiv: true, + offen, + shop: shopOeffentlich(e), + kategorien, + produkte, + rechtstexte: rechtstexteOeffentlich(ctx), + gewaehrleistung: { bild: `${basisUrl()}${GEWAEHRLEISTUNG_BILD}`, text: gewaehrleistungsHinweis() }, + widerrufUrl, + formToken: token, + }, + } + }), +) + +// ---------------------------------------------------------------- Herkunft, POST-Hilfen + +const MELDUNG_NEU_LADEN = 'Bitte laden Sie die Seite neu.' +const nichtErlaubt = () => fehlerMit(403, { _: 'Nicht erlaubt' }) +/** POST-Routen, deren Preflight dieselbe Pruefung bekommt (F3 widerruf; F2 pruefen, bestellung). */ +const POST_ROUTEN = new Set(['widerruf', 'pruefen', 'bestellung']) + +/** + * Die Origin der Anfrage, wenn sie POST und Preflight darf: Host in analyseDomains(project) (Seiten des Betriebs), Host von + * config.publicBaseUrl (Seiten des Portals) oder gleiche Herkunft wie die Anfrage (Host-Header). Sonst null (fehlende, fremde, + * unlesbare Origin oder nicht in kanonischer Schreibweise). + */ +function erlaubteHerkunft(req, project) { + const origin = req.get('origin') + if (typeof origin !== 'string' || origin === '') return null + let url + try { + url = new URL(origin) + } catch { + return null + } + if ((url.protocol !== 'https:' && url.protocol !== 'http:') || url.origin !== origin) return null + const erlaubt = new Set([String(req.get('host') || '').toLowerCase()]) + try { + erlaubt.add(new URL(config.publicBaseUrl).host.toLowerCase()) + } catch { + // Portal-Adresse unlesbar: dann gilt nur die gleiche Herkunft + } + if (project) for (const domain of analyseDomains(project)) erlaubt.add(domain) + return erlaubt.has(url.host) ? origin : null +} + +/** Prueft die Herkunft eines POST und merkt sie in cors; sonst 403. */ +function pruefeHerkunft(req, cors, project) { + const origin = erlaubteHerkunft(req, project) + if (!origin) throw nichtErlaubt() + cors.erlaubt = origin +} + +/** Antwort auf einen Preflight (204): erlaubte Methoden, Content-Type, 24 Stunden gueltig. */ +const vorabantwort = (origin) => ({ + status: 204, + body: '', + headers: { + 'Access-Control-Allow-Origin': origin, + 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type', + 'Access-Control-Max-Age': '86400', + Vary: 'Origin', + }, +}) + +// Preflight unter /:siteKey/: erlaubt fuer Herkunft des Betriebs, des Portals und gleiche Herkunft; ohne +// erreichbaren Shop (unbekannt, off, Pilot) gibt es nichts zu pruefen und nichts zu verraten. +router.options( + '/:siteKey/:was', + route(async (req, now, cors) => { + if (!POST_ROUTEN.has(req.params.was)) throw nichtGefunden() + if (!darfAnfrage(req.ip, now)) throw zuViele(60) + // Widerruf: auch ohne Bestellmodus erreichbar (siehe widerrufZugang); alle anderen Routen nur bei erreichbarem Shop + const ctx = req.params.was === 'widerruf' ? (await widerrufZugang(req.params.siteKey, now)).ctx : await erreichbarerShop(req.params.siteKey, now) + if (!ctx) return vorabantwort('*') + pruefeHerkunft(req, cors, ctx.project) + return vorabantwort(cors.erlaubt) + }), +) + +// ---------------------------------------------------------------- Widerruf + +/** + * Honigtopf `kontakt_url` (jeder Wert ausser leer) und Formular-Token: Honigtopf oder zu schnell abgeschickt => dieselbe + * Erfolgsantwort wie sonst, gespeichert wird nichts; fehlendes, fremdes oder altes Token => 400 "Seite neu laden". + * @returns {boolean} true, wenn nur so getan werden soll (Falle) + */ +function istFalle(siteKey, b, now) { + const honig = b.kontakt_url + if (honig !== undefined && honig !== null && !(typeof honig === 'string' && honig.trim() === '')) return true + const token = pruefeFormToken(siteKey, b.formToken, now) + if (token === 'zu_schnell') return true + if (token !== 'ok') throw fehlerMit(400, { _: MELDUNG_NEU_LADEN }, { code: 'neu_laden' }) + return false +} + +router.post( + '/:siteKey/widerruf', + route( + async (req, now, cors) => { + const { siteKey } = req.params + if (!darfAnfrage(req.ip, now)) throw zuViele(60) + const { ctx } = await widerrufZugang(siteKey, now) // active und disabled, unabhaengig von SHOP_ENABLED und Pilotliste: der Widerruf bleibt moeglich + if (!ctx) return aktivFalse(req) + pruefeHerkunft(req, cors, ctx.project) + const b = req.body + if (!istObjekt(b)) throw fehlerMit(400, { _: MELDUNG_NEU_LADEN }, { code: 'neu_laden' }) + if (istFalle(siteKey, b, now)) return { body: { ok: true, eingang: eingangText(now) } } + + let ergebnis + try { + ergebnis = await erfasseWiderruf(ctx, { name: b.name, email: b.email, bestellnummer: b.bestellnummer, nachricht: b.nachricht }, { now, ip: req.ip }) + } catch (err) { + if (err?.status === 429) throw zuViele(3600) + throw err + } + return { body: { ok: true, eingang: ergebnis.eingang } } + }, + { mindestdauer: true }, + ), +) + +// ---------------------------------------------------------------- Pruefen, Bestellung, Status (F2) + +const PRUEFEN_JE_IP = 60 +const BESTELLUNG_JE_IP = 10 +const BESTELLUNG_FENSTER_MS = 10 * MINUTE_MS +const BESTELLUNG_JE_SHOP = 300 +const STATUS_JE_IP = 120 +const STUNDE_MS = 60 * MINUTE_MS +const BESTELL_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,35}$/ +const BESTELL_TOKEN_RE = /^[A-Za-z0-9_-]{16,128}$/ +const GESCHLOSSEN_TEXT = 'Der Shop ist gerade geschlossen.' +const GESCHLOSSEN = () => fehlerMit(409, { _: GESCHLOSSEN_TEXT }, { code: 'geschlossen' }) +// Fehlercodes aus berechne/legeBestellungAn, die Kaeufer sehen duerfen (Stripe-Fehler haben ebenfalls einen String-code) +const KAEUFER_CODES = new Set(['geschlossen', 'leer', 'nicht_verfuegbar', 'menge', 'variante', 'ausverkauft', 'lieferart', 'land', 'nur_abholung', 'mindestbestellwert', 'mindestbetrag', 'zu_viele', 'zu_viele_offen', 'zahlung_nicht_moeglich']) +const KONFLIKT_CODES = new Set(['ausverkauft', 'nicht_verfuegbar', 'nur_abholung']) + +/** Artikel-Map fuer berechne: id => normalisiertes Produkt (aus dem JSON-Text), aktiv aus Spalte und JSON (wie bei legeBestellungAn). */ +function produktKarte(docs) { + const karte = new Map() + for (const doc of docs) { + let p + try { + p = JSON.parse(doc.produkt) + } catch { + continue + } + if (istObjekt(p)) karte.set(doc.$id, { ...p, id: doc.$id, aktiv: doc.aktiv !== false && p.aktiv !== false }) + } + return karte +} + +/** Fehler aus legeBestellungAn fuer den Kaeufer: eigene Fehler (bekannter code) mit ihrem Text, alles andere 503 ohne Rohtext. */ +function bestellFehler(err) { + if (typeof err?.code === 'string' && KAEUFER_CODES.has(err.code) && Number.isInteger(err.status) && err.status >= 400 && err.status < 600) { + const extra = { code: err.code, ...(typeof err.produktId === 'string' ? { produktId: err.produktId } : {}), ...(Number.isInteger(err.retryAfter) ? { retryAfter: err.retryAfter } : {}) } + if (err.status < 500) return fehlerMit(err.status, { _: String(err.message) }, extra) + return Object.assign(new Error(String(err.message)), { status: 503, fehler: { _: String(err.message) }, ...extra, dienst: true }) + } + return err +} + +// 5xx mit Kaeufertext (zahlung_nicht_moeglich): fehlerErgebnis kennt nur 4xx, also hier eigene Abbildung +const bestellErgebnis = (err) => { + const e = bestellFehler(err) + if (e?.dienst) return { status: 503, body: { fehler: e.fehler, code: e.code } } + throw e +} + +/** Eingabe-Objekt des Kaeufers: nur positionen, lieferart und land (Preise, Namen u. a. werden nie gelesen). */ +const warenkorbEingabe = (b) => ({ positionen: b.positionen, lieferart: b.lieferart, land: b.land }) + +router.post( + '/:siteKey/pruefen', + route(async (req, now, cors) => { + const { siteKey } = req.params + if (!zaehle(`ip:shop-pruefen:${ipSchluessel(req.ip)}`, PRUEFEN_JE_IP, MINUTE_MS, now)) throw zuViele(60) + const ctx = await erreichbarerShop(siteKey, now) + if (!ctx) return aktivFalse(req) + pruefeHerkunft(req, cors, ctx.project) + const b = req.body + if (!istObjekt(b)) throw fehlerMit(400, UNGUELTIG.fehler) + + const docs = await listeProdukte(ctx.shop.id) + const [offen, belegt] = await Promise.all([istGeoeffnet(ctx, { produkte: docs }), reserviert(ctx.shop.id, now)]) + if (!offen) throw GESCHLOSSEN() + const eingabe = warenkorbEingabe(b) + const ergebnis = berechne({ produkte: produktKarte(docs), ...eingabe, einstellungen: ctx.shop.einstellungen, reserviert: belegt }) + if (!ergebnis.ok) { + const { code, text, produktId } = ergebnis.fehler + throw fehlerMit(KONFLIKT_CODES.has(code) ? 409 : 400, { _: text }, { code, ...(produktId ? { produktId } : {}) }) + } + const widerrufTexte = new Map() + for (const doc of docs) widerrufTexte.set(doc.$id, produktOeffentlich(doc, { einstellungen: ctx.shop.einstellungen, siteKey })?.widerrufText ?? '') + return { + body: { + ok: true, + positionen: ergebnis.positionen.map((p) => ({ + produktId: p.produktId, + varianteId: p.varianteId, + name: p.name, + varianteText: p.varianteText, + menge: p.menge, + einzelText: p.einzelText, + summeText: p.summeText, + widerrufText: widerrufTexte.get(p.produktId) ?? '', + })), + texte: ergebnis.texte, + lieferart: ergebnis.lieferart, + land: ergebnis.land, + }, + } + }), +) + +router.post( + '/:siteKey/bestellung', + route( + async (req, now, cors) => { + const { siteKey } = req.params + if (!zaehle(`ip:shop-bestellung:${ipSchluessel(req.ip)}`, BESTELLUNG_JE_IP, BESTELLUNG_FENSTER_MS, now)) throw zuViele(600) + const ctx = await erreichbarerShop(siteKey, now) + if (!ctx) return aktivFalse(req) + pruefeHerkunft(req, cors, ctx.project) + if (!zaehle(`shop-bestellung:${siteKey}`, BESTELLUNG_JE_SHOP, STUNDE_MS, now)) throw zuViele(3600) + const b = req.body + if (!istObjekt(b)) throw fehlerMit(400, { _: MELDUNG_NEU_LADEN }, { code: 'neu_laden' }) + if (istFalle(siteKey, b, now)) return { body: { url: pruefeRueckUrl(b.rueckUrl, ctx) } } + + try { + const { url } = await legeBestellungAn(ctx, { ...warenkorbEingabe(b), rueckUrl: b.rueckUrl }, { now, ipHash: ipHashVon(req.ip) }) + return { body: { url } } + } catch (err) { + return bestellErgebnis(err) + } + }, + { mindestdauer: true }, + ), +) + +/** E-Mail-Adresse verdeckt: erstes Zeichen, ***, Domain ('m***@example.com'); leer, wenn es keine gibt. */ +function maskiereEmail(email) { + const t = typeof email === 'string' ? email.trim() : '' + const at = t.lastIndexOf('@') + if (at < 1 || at === t.length - 1) return '' + return `${t[0]}***@${t.slice(at + 1)}` +} + +router.get( + '/:siteKey/bestellung/:id', + route(async (req, now) => { + const { siteKey, id } = req.params + if (!zaehle(`ip:shop-status:${ipSchluessel(req.ip)}`, STATUS_JE_IP, MINUTE_MS, now)) throw zuViele(60) + const keine = () => fehlerMit(404, { _: 'Bestellung nicht gefunden.' }) // fuer jeden Fehlerfall dieselbe Antwort + const t = typeof req.query.t === 'string' ? req.query.t : '' + if (!BESTELL_ID_RE.test(id) || !BESTELL_TOKEN_RE.test(t)) throw keine() + const ctx = await erreichbarerShop(siteKey, now) // active und disabled + if (!ctx) throw keine() + let order = await bestellungLesen(id) + if (!order || order.shopId !== ctx.shop.id || !tokenPasst(order, t)) throw keine() + + // Noch offen: einmal bei Stripe nachsehen (der Webhook kann spaeter kommen als die Rueckkehr des Kaeufers); bezahlt, aber mit + // fehlendem Bestandsabzug oder fehlenden Mails (Teilfehler): schliesseAb holt die Schritte nach + const accountId = ctx.shop.stripeAccountId + if ((order.status === 'offen' || abschlussAusstehend(order)) && order.stripeSessionId && accountId) { + try { + const session = await sessionHolen(accountId, order.stripeSessionId) + if (session?.status === 'complete' && session.payment_status === 'paid') { + await schliesseAb({ accountId, session, now }) + order = (await bestellungLesen(id)) ?? order + } + } catch (err) { + console.warn(`[publicShop] Bestellstatus: Stripe-Abgleich nicht möglich: ${err?.name ?? 'Fehler'}: ${String(err?.message ?? '').slice(0, 120)}`) + } + } + const gesamt = order.daten?.summen?.texte?.gesamt + return { + body: { + status: String(order.status ?? ''), + bestellnummer: String(order.bestellnummer ?? ''), + gesamtText: typeof gesamt === 'string' && gesamt !== '' ? gesamt : euro(Number.isInteger(order.summeCent) ? order.summeCent : 0), + lieferart: order.daten?.lieferart === 'abholung' ? 'abholung' : 'versand', + email: maskiereEmail(order.kaeuferEmail), + }, + } + }), +) + +// ---------------------------------------------------------------- Artikelbilder + +router.get( + '/:siteKey/bild/:datei', + route(async (req, now) => { + const { siteKey, datei } = req.params + const ip = ipSchluessel(req.ip) + if (!zaehle(`ip:shop-bild:${ip}`, BILD_JE_IP, MINUTE_MS, now)) throw zuViele(60) + const format = BILD_RE.exec(datei) + const ctx = format ? await erreichbarerShop(siteKey, now) : null + if (!ctx || ctx.doc.state !== 'active') throw nichtGefunden() // ohne Hinweis, was nicht stimmt + if (!zaehle(`shop-bild:${siteKey}`, BILD_JE_SHOP, MINUTE_MS, now)) throw zuViele(60) + + // Das Bild muss zu einem aktiven Artikel dieses Shops gehoeren + const [, bildId, groesse] = format + const gehoert = (await listeProdukte(ctx.shop.id)).some((doc) => doc.aktiv !== false && (parseProdukt(doc)?.bilder ?? []).includes(bildId)) + const pfad = gehoert && (await bildMeta(siteKey, bildId)) ? bildPfad(siteKey, bildId, groesse) : null + if (!pfad) throw nichtGefunden() + return { datei: pfad, cache: 'public, max-age=86400' } + }), +) + +// ---------------------------------------------------------------- Rest: unbekannte Pfade, Fehler des Parsers + +router.use((req, res) => schreibeSicher(req, res, { status: 404, body: NICHT_GEFUNDEN })) + +// Fehlerbehandlung des Routers (Parser): 413 zu gross, 400 kaputt oder nicht lesbar, sonst 503; nie der Text des Fehlers +router.use((err, req, res, _next) => { + if (err?.type === 'entity.too.large' || err?.status === 413) return schreibeSicher(req, res, { status: 413, body: ZU_GROSS }) + if (Number.isInteger(err?.status) && err.status >= 400 && err.status < 500) return schreibeSicher(req, res, { status: 400, body: UNGUELTIG }) + return schreibeSicher(req, res, fehlerErgebnis(err)) +}) + +export default router diff --git a/server/routes/shop.js b/server/routes/shop.js new file mode 100644 index 0000000..ff38433 --- /dev/null +++ b/server/routes/shop.js @@ -0,0 +1,653 @@ +import express, { Router } from 'express' +import { getSessionCustomerId, requireSession } from '../middleware/session.js' +import { assertOwnProject, getFeatureDoc } from '../services/siteFeatures.js' +import { countDocuments, getDocument, listAllDocuments, Query, updateDocument } from '../services/appwriteAdmin.js' +import { config } from '../config.js' +import { withLocks } from '../services/keyedLock.js' +import { isKundenansicht, logIfKundenansicht } from '../services/kundenansicht.js' +import { createWriteLimiter } from '../services/writeLimiter.js' +import { anfrageAnsicht, ensureShopConfig, leseShopConfig, stelleAnfrage, zieheZurueck } from '../services/shop/anfrage.js' +import { shopFuerKunde } from '../services/shop/schalter.js' +import { kontoAnlegen, kontoStatus, onboardingLink, statusAusKonto } from '../services/shop/connect.js' +import { bestellungKunde, bestellungLesen, csv as bestellungenCsv, erstatte, listeBestellungen, setzeStatus } from '../services/shop/bestellungen.js' +import { listeWiderrufe, markiereBearbeitet } from '../services/shop/widerrufe.js' +import { uploadSlot } from './chat.js' +import { istVollstaendig, speichereEinstellungen } from '../services/shop/einstellungen.js' +import { bereitschaft, istGeoeffnet, ladeShop, leereCache } from '../services/shop/kontext.js' +import { + bildIdsInGebrauch, holeProdukt, leseBestandGeladen, listeProdukte, loescheProdukt, normalisiereProdukt, parseProdukt, sortiere, speichereProdukt, +} from '../services/shop/produkte.js' +import { archiviereRechtstexte } from '../services/shop/archiv.js' +import { + BESTAETIGUNG_TEXT, GEWAEHRLEISTUNG_BILD, gewaehrleistungsHinweis, musterAgb, musterWiderrufsbelehrung, musterWiderrufsformular, + normalisiereRechtstexte, pruefeBestaetigbar, rechtstexteHash, versandUndZahlung, vollstaendig, +} from '../services/shop/rechtstexte.js' +import { + BILD_MAX_BYTES, beanspruche, bildLimitErreicht, bildMeta, bildPfad, loescheBild, setzeBildLimitZurueck, speichereBild, +} from '../services/shop/bilder.js' + +/** + * Kunden-Schnittstelle des Webshops: /api/shop (Spezifikation 2026-10-05-webshop-design.md). Stand: Anfrage (B1) und + * Verwaltung (C7): Status, Einstellungen, Artikel, Rechtstexte, Oeffnen, Bilder. Stripe-Konto (D2) und Bestellungen (E) kommen dazu. + * + * Jede Route: Session, projectId (Query oder Body), eigenes Projekt. Die Verwaltungs-Routen laufen ueber mitShop: Schalter + * (409 nicht verfuegbar), Funktion im Zustand setup|active (lesend auch disabled), shopConfig vorhanden (setup|active legt es an). + * Kundenansicht: Artikel, Einstellungen, Rechtstexte (nur Entwurf speichern) und Bilder darf das Team mit ausfuellen und werden + * protokolliert (kundenansichtGuard); Anfrage, Oeffnen und Bestaetigen der Rechtstexte bleiben dem Kunden vorbehalten (hier 403). + * Schreib-Routen zaehlen gegen 300 Aufrufe je Kunde und Stunde (Anfrage-Routen gegen 20), Bilder zusaetzlich gegen 40 je Stunde. + * Fehler: { error: , fehler?: { : Text }, code? }; Texte von Appwrite oder unbekannten Fehlern verlassen den Server nie. + */ + +const router = Router() + +const MAX_ANFRAGE_WRITES_PER_HOUR = 20 +const MAX_WRITES_PER_HOUR = 300 +const MAX_CSV_PER_HOUR = 30 +const PRODUKT_DOC_MAX_JSON = 100000 +const BILD_DATEI_RE = /^([a-z0-9-]{1,64})-(l|s)\.webp$/ +const MELDUNG = { + projekt: 'Bitte wähle ein Projekt.', + fremd: 'Kein Zugriff auf dieses Projekt.', + kundenansicht: 'In der Kundenansicht kann das nicht geändert werden.', + zuViele: 'Zu viele Änderungen in kurzer Zeit. Bitte versuch es in einer Stunde wieder.', + nichtVerfuegbar: 'Der Webshop ist noch nicht verfügbar.', + nichtEingerichtet: 'Der Webshop ist für dieses Projekt noch nicht eingerichtet.', + zuVieleBilder: 'Zu viele Bilder in kurzer Zeit. Bitte versuch es später wieder.', + bildFehlt: 'Bild nicht gefunden.', + eingabe: 'Die Eingabe fehlt oder ist unlesbar.', + stripeKundenansicht: 'In der Kundenansicht kann das Stripe-Konto nicht eingerichtet werden.', + stripeFern: 'Stripe ist gerade nicht erreichbar. Bitte versuch es gleich noch einmal.', + keinKonto: 'Es ist noch kein Stripe-Konto angelegt.', +} + +/** Fehler des Kunden (4xx/503 ohne Appwrite-Typ) wie sie sind, alles andere 500 ohne Rohtext. */ +function fail(res, err) { + const own = ((err?.status >= 400 && err.status < 500) || err?.status === 502 || err?.status === 503) && !err.type + if (!own) { + console.error(`[shop] ${err?.name ?? 'Fehler'} ${err?.status ?? ''} ${String(err?.message ?? '').slice(0, 200)}`.trim()) + return res.status(500).json({ error: 'Interner Serverfehler' }) + } + const body = { ...err.body, error: err.message } + if (err.fehler && typeof err.fehler === 'object') { + body.fehler = err.fehler + const erster = Object.values(err.fehler).find((t) => typeof t === 'string') + if (erster) body.error = erster + } + if (typeof err.code === 'string' && /^[A-Za-z0-9_]{1,40}$/.test(err.code) && body.code === undefined) body.code = err.code + return res.status(err.status).json(body) +} + +const route = (fn) => async (req, res, next) => { + try { + await fn(req, res, next) + } catch (err) { + fail(res, err) + } +} + +const anfrageLimiter = createWriteLimiter({ max: MAX_ANFRAGE_WRITES_PER_HOUR }) +const limitAnfrage = anfrageLimiter.middleware((req) => getSessionCustomerId(req), MELDUNG.zuViele) +const schreibLimiter = createWriteLimiter({ max: MAX_WRITES_PER_HOUR }) +const csvLimiter = createWriteLimiter({ max: MAX_CSV_PER_HOUR }) +const limitCsv = csvLimiter.middleware((req) => getSessionCustomerId(req), MELDUNG.zuViele) +const limitWrites = schreibLimiter.middleware((req) => getSessionCustomerId(req), MELDUNG.zuViele) + +export function resetShopRoutesForTests() { + anfrageLimiter.reset() + schreibLimiter.reset() + csvLimiter.reset() + setzeBildLimitZurueck() +} + +const nurKunde = (req, res, next) => { + if (isKundenansicht(req.session) || req.session?.viewAs) return res.status(403).json({ error: MELDUNG.kundenansicht }) + return next() +} + +const person = (req) => ({ name: req.session?.name || '', email: req.session?.email || '' }) +const projectIdOf = (req) => { + const raw = req.params?.projectId ?? req.query.projectId ?? req.body?.projectId + return typeof raw === 'string' ? raw : '' +} + +router.use((_req, res, next) => { + res.set('Cache-Control', 'no-store') + next() +}) + +const STRIPE_FELDER = ['charges_enabled', 'payouts_enabled', 'details_submitted', 'currently_due', 'past_due', 'disabled_reason', 'geprueftAt'] +const BEZAHLT = ['bezahlt', 'versendet', 'abholbereit', 'abgeschlossen'] + +/** Stripe-Status fuer den Browser: nur bekannte Felder, nie Konto-ID oder Schluessel. */ +function stripeAnsicht(shop) { + const roh = shop?.stripeStatus + const status = roh ? Object.fromEntries(STRIPE_FELDER.filter((k) => Object.hasOwn(roh, k)).map((k) => [k, roh[k]])) : null + return { verbunden: Boolean(shop?.stripeAccountId), status } +} + +/** Kennzahlen der Uebersicht (nur Anzeige): Artikel und, best effort, Bestellungen. Ein Lesefehler bei den Bestellungen zaehlt als 0. */ +async function kennzahlen(shopId, produkte, now = new Date()) { + const zahlen = { + artikel: produkte.length, + aktiveArtikel: produkte.filter((d) => d.aktiv !== false).length, + offeneBestellungen: 0, + bestellungen30: 0, + umsatz30Cent: 0, + } + try { + zahlen.offeneBestellungen = await countDocuments(config.collections.shopOrders, [Query.equal('shopId', shopId), Query.equal('status', 'bezahlt')]) + const seit = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000) + const neu = await listAllDocuments(config.collections.shopOrders, [Query.equal('shopId', shopId), Query.greaterThanEqual('erstelltAt', seit.toISOString())]) + for (const o of neu) { + if (!BEZAHLT.includes(o.status)) continue + zahlen.bestellungen30 += 1 + if (Number.isInteger(o.summeCent)) zahlen.umsatz30Cent += o.summeCent + } + } catch (err) { + console.warn(`[shop] Kennzahlen der Bestellungen nicht lesbar: ${String(err?.message ?? '').slice(0, 120)}`) + } + return zahlen +} + +const einbauSnippet = (siteKey) => `
      \n` +const vorschauUrl = (siteKey) => `${config.publicBaseUrl}/shop/${siteKey}` +const widerrufUrl = (siteKey) => `${config.publicBaseUrl}/shop/${siteKey}/widerruf` + +async function ticketWoid(ticketId) { + if (!ticketId) return null + try { + return (await getDocument(config.collections.workorders, ticketId))?.woid || null + } catch { + return null + } +} + +/** + * Stand des Webshops fuer ein Projekt. verfuegbar = Schalter fuer diesen Kunden; state = Zustand der Funktion (off ohne Dokument); + * anfrage = anfrageAnsicht oder null; mit shopConfig dazu: ticketWoid, bereitschaft ({ bereit, punkte }), geoeffnet (verkauft der Shop + * gerade), schalter (Schalter des Kunden), siteKey, snippet, vorschauUrl, stripe ({ verbunden, status }), zahlen. Legt nichts an. + */ +router.get('/status', requireSession, route(async (req, res) => { + const projectId = projectIdOf(req) + if (!projectId) throw Object.assign(new Error(MELDUNG.projekt), { status: 400 }) + const customerId = getSessionCustomerId(req) + try { + await assertOwnProject(customerId, projectId) + } catch (err) { + throw err?.status === 403 || err?.status === 400 ? Object.assign(new Error(MELDUNG.fremd), { status: 403 }) : err + } + const verfuegbar = shopFuerKunde(req.session?.email) + const feature = await getFeatureDoc(customerId, projectId, 'shop') + const shopDoc = feature ? await leseShopConfig(feature.$id) : null + const antwort = { + verfuegbar, + state: feature?.state || 'off', + ticketWoid: null, + anfrage: shopDoc ? anfrageAnsicht(shopDoc) : null, + bereitschaft: null, + geoeffnet: false, + schalter: false, + siteKey: feature?.siteKey || null, + snippet: null, + vorschauUrl: null, + stripe: { verbunden: false, status: null }, + zahlen: null, + } + if (feature && shopDoc) { + const shop = ladeShop(shopDoc) + const produkte = await listeProdukte(shop.id) + const ctx = { doc: feature, shop, project: null } + antwort.ticketWoid = await ticketWoid(feature.ticketId) + antwort.bereitschaft = await bereitschaft(ctx, { produkte }) + antwort.geoeffnet = await istGeoeffnet(ctx, { produkte }) + antwort.schalter = shop.geoeffnet + antwort.snippet = einbauSnippet(shop.siteKey || feature.siteKey) + antwort.vorschauUrl = vorschauUrl(shop.siteKey || feature.siteKey) + antwort.stripe = stripeAnsicht(shop) + antwort.zahlen = await kennzahlen(shop.id, produkte) + } + res.json(antwort) +})) + +router.post('/anfrage', requireSession, nurKunde, limitAnfrage, route(async (req, res) => { + const anfrage = await stelleAnfrage({ + customerId: getSessionCustomerId(req), + projectId: projectIdOf(req), + eingabe: req.body?.anfrage, + person: person(req), + }) + res.json({ anfrage }) +})) + +router.post('/anfrage/zurueckziehen', requireSession, nurKunde, limitAnfrage, route(async (req, res) => { + const anfrage = await zieheZurueck({ customerId: getSessionCustomerId(req), projectId: projectIdOf(req), person: person(req) }) + res.json({ anfrage }) +})) + +// ---------------------------------------------------------------- Verwaltung (C7) + +const stop = (status, text, code, fehlerFelder) => + Object.assign(new Error(text), { status, ...(code ? { body: { code } } : {}), ...(fehlerFelder ? { fehler: fehlerFelder } : {}) }) +const eingabeFehler = (fehlerFelder) => stop(400, Object.values(fehlerFelder)[0], undefined, fehlerFelder) +const personLabel = (p) => (p.name ? `${p.name}${p.email ? ` <${p.email}>` : ''}` : p.email || 'Kunde') + +/** + * Gemeinsame Pruefung der Verwaltungs-Routen: projectId, eigenes Projekt (403; archiviert nur lesend), Schalter (409 + * nicht_verfuegbar), Funktion im Zustand zustaende (Vorgabe: schreibend setup|active, lesend setup|active|disabled; sonst 409 + * nicht_eingerichtet) und shopConfig (fehlt es und der Zustand ist setup|active, wird es angelegt). + * @returns {Promise<{ customerId: string, projectId: string, project: object, doc: object, shopDoc: object, shop: object }>} + */ +async function mitShop(req, { schreiben = false, zustaende } = {}) { + const projectId = projectIdOf(req) + if (!projectId) throw stop(400, MELDUNG.projekt) + const customerId = getSessionCustomerId(req) + let project + try { + project = await assertOwnProject(customerId, projectId, { forNewOrder: schreiben }) + } catch (err) { + throw err?.status === 403 || err?.status === 400 ? stop(403, MELDUNG.fremd) : err + } + if (!shopFuerKunde(req.session?.email)) throw stop(409, MELDUNG.nichtVerfuegbar, 'nicht_verfuegbar') + const erlaubt = zustaende || (schreiben ? ['setup', 'active'] : ['setup', 'active', 'disabled']) + const doc = await getFeatureDoc(customerId, projectId, 'shop') + if (!doc || !erlaubt.includes(doc.state)) throw stop(409, MELDUNG.nichtEingerichtet, 'nicht_eingerichtet') + let shopDoc = await leseShopConfig(doc.$id) + if (!shopDoc && ['setup', 'active'].includes(doc.state)) shopDoc = await ensureShopConfig(doc) + if (!shopDoc) throw stop(409, MELDUNG.nichtEingerichtet, 'nicht_eingerichtet') + return { customerId, projectId, project, doc, shopDoc, shop: ladeShop(shopDoc) } +} + +/** Middleware: mitShop vor dem Einlesen eines Uploads; Ergebnis in req.shopCtx. */ +const mitShopSchreibend = route(async (req, _res, next) => { + req.shopCtx = await mitShop(req, { schreiben: true }) + next() +}) + +const protokoll = (req, text) => logIfKundenansicht(req, 'shop_aenderung', text) + +// ---- Einstellungen + +const einstellungenAntwort = (shop) => ({ einstellungen: shop.einstellungen, version: shop.version, vollstaendig: istVollstaendig(shop.einstellungen) }) + +router.get('/einstellungen', requireSession, route(async (req, res) => { + const { shop } = await mitShop(req) + res.json(einstellungenAntwort(shop)) +})) + +router.put('/einstellungen', requireSession, limitWrites, route(async (req, res) => { + const { shop } = await mitShop(req, { schreiben: true }) + const gespeichert = await speichereEinstellungen(shop.id, req.body?.einstellungen, { erwarteteVersion: Number.isInteger(req.body?.version) ? req.body.version : undefined }) + await protokoll(req, 'Einstellungen gespeichert') + res.json({ einstellungen: gespeichert.einstellungen, version: gespeichert.version, vollstaendig: istVollstaendig(gespeichert.einstellungen) }) +})) + +// ---- Artikel + +/** Artikel fuer den Haendler: alle gespeicherten Felder, dazu id, aktiv, sort und Vorschau-Adressen der Bilder. */ +function produktAnsicht(doc, projectId) { + const p = parseProdukt(doc) || {} + const bilder = Array.isArray(p.bilder) ? p.bilder.filter((b) => typeof b === 'string') : [] + return { + ...p, + id: doc.$id, + name: typeof p.name === 'string' ? p.name : doc.name || '', + aktiv: doc.aktiv !== false, + sort: Number.isInteger(doc.sort) ? doc.sort : 0, + bilder, + bildUrls: bilder.map((id) => ({ + id, + klein: `/api/shop/bild/${encodeURIComponent(projectId)}/${id}-s.webp`, + gross: `/api/shop/bild/${encodeURIComponent(projectId)}/${id}-l.webp`, + })), + } +} + +const bildIdsVon = (produkt) => (Array.isArray(produkt?.bilder) ? produkt.bilder.filter((b) => typeof b === 'string') : []) + +/** Bilder, die nach einer Aenderung kein Artikel des Shops mehr verwendet, loeschen. */ +async function raeumeBilderAuf(shop, kandidaten) { + if (kandidaten.length === 0) return + const inGebrauch = await bildIdsInGebrauch(shop.id) + for (const id of kandidaten) if (!inGebrauch.has(id)) await loescheBild(shop.siteKey, id) +} + +router.get('/produkte', requireSession, route(async (req, res) => { + const { shop, projectId } = await mitShop(req) + const docs = await listeProdukte(shop.id) + res.json({ produkte: docs.map((d) => produktAnsicht(d, projectId)), maxArtikel: config.shop.maxArtikel, maxBilder: config.shop.maxBilder }) +})) + +/** Artikel anlegen (id null) oder aendern: normalisieren, Bilder pruefen und beanspruchen, entfernte Bilder aufraeumen. */ +async function speichereArtikel(req, res, id) { + const { shop, customerId, projectId } = await mitShop(req, { schreiben: true }) + const { produkt, fehler } = normalisiereProdukt(req.body?.produkt, { einstellungen: shop.einstellungen }) + if (fehler) throw eingabeFehler(fehler) + const neueBilder = bildIdsVon(produkt) + const doc = await withLocks([`shop-bilder:${shop.id}`], async () => { + let vorher = [] + if (id) { + const alt = await holeProdukt(shop.id, id) + if (!alt) throw stop(404, 'Diesen Artikel gibt es nicht (mehr).') + vorher = bildIdsVon(parseProdukt(alt)) + } + for (const bildId of neueBilder) { + const meta = await bildMeta(shop.siteKey, bildId) + if (!meta || meta.shopId !== shop.id) throw eingabeFehler({ bilder: 'Eines der Bilder gibt es nicht (mehr). Bitte lade es neu hoch.' }) + } + const gespeichert = await speichereProdukt({ shopId: shop.id, customerId }, id, produkt, { geladen: leseBestandGeladen(req.body?.produkt) }) + await beanspruche(shop.siteKey, neueBilder) + await raeumeBilderAuf(shop, vorher.filter((b) => !neueBilder.includes(b))) + return gespeichert + }) + await protokoll(req, `Artikel ${id ? 'geändert' : 'angelegt'}: ${produkt.name}`) + res.status(id ? 200 : 201).json({ produkt: produktAnsicht(doc, projectId) }) +} + +router.post('/produkte', requireSession, limitWrites, route((req, res) => speichereArtikel(req, res, null))) + +router.post('/produkte/sortierung', requireSession, limitWrites, route(async (req, res) => { + const { shop, projectId } = await mitShop(req, { schreiben: true }) + const docs = await sortiere(shop.id, req.body?.ids) + await protokoll(req, 'Artikel sortiert') + res.json({ produkte: docs.map((d) => produktAnsicht(d, projectId)) }) +})) + +router.put('/produkte/:id', requireSession, limitWrites, route((req, res) => speichereArtikel(req, res, req.params.id))) + +router.delete('/produkte/:id', requireSession, limitWrites, route(async (req, res) => { + const { shop } = await mitShop(req, { schreiben: true }) + const name = await withLocks([`shop-bilder:${shop.id}`], async () => { + const alt = await holeProdukt(shop.id, req.params.id) + if (!alt) throw stop(404, 'Diesen Artikel gibt es nicht (mehr).') + await loescheProdukt(shop.id, req.params.id) + await raeumeBilderAuf(shop, bildIdsVon(parseProdukt(alt))) + return alt.name || '' + }) + await protokoll(req, `Artikel gelöscht: ${name}`) + res.json({ ok: true }) +})) + +// ---- Rechtstexte + +/** Antwort der Rechtstexte: Entwurf, zuletzt bestaetigte Fassung, Muster zum Zuruecksetzen, erzeugte Texte und Hash des Entwurfs. */ +function rechtstexteAntwort(shop) { + const e = shop.einstellungen + const { entwurf, bestaetigt } = shop.rechtstexte + const hash = rechtstexteHash(entwurf, e) + return { + entwurf, + bestaetigt: bestaetigt ? { texte: bestaetigt.texte, at: bestaetigt.at, von: bestaetigt.von, aktuell: bestaetigt.hash === hash } : null, + muster: { + agb: musterAgb(e), + widerrufsbelehrung: musterWiderrufsbelehrung(e, { widerrufUrl: widerrufUrl(shop.siteKey) }), + widerrufsformular: musterWiderrufsformular(e), + }, + versandUndZahlung: versandUndZahlung(e), + gewaehrleistung: { bild: GEWAEHRLEISTUNG_BILD, text: gewaehrleistungsHinweis() }, + hash, + bestaetigungText: BESTAETIGUNG_TEXT, + vollstaendig: vollstaendig(entwurf), + // Was das Bestaetigen des gespeicherten Entwurfs derzeit verhindert (Platzhalter, Musterkopf, fehlende Adresse der Widerrufsfunktion) + probleme: pruefeBestaetigbar(entwurf, { widerrufUrl: widerrufUrl(shop.siteKey) }), + } +} + +/** shopConfig frisch lesen (unter der Sperre) und als geparsten Shop liefern. */ +async function frischerShop(shopId) { + return ladeShop(await getDocument(config.collections.shopConfig, shopId)) +} + +router.get('/rechtstexte', requireSession, route(async (req, res) => { + const { shop } = await mitShop(req) + res.json(rechtstexteAntwort(shop)) +})) + +router.put('/rechtstexte', requireSession, limitWrites, route(async (req, res) => { + const { shop } = await mitShop(req, { schreiben: true }) + const { rechtstexte, fehler } = normalisiereRechtstexte(req.body?.rechtstexte) + if (fehler) throw eingabeFehler(fehler) + const neu = await withLocks([`shop-rt:${shop.id}`], async () => { + const aktuell = await frischerShop(shop.id) + const json = JSON.stringify({ entwurf: rechtstexte, bestaetigt: aktuell.rechtstexte.bestaetigt }) + if (json.length > PRODUKT_DOC_MAX_JSON) throw stop(400, 'Die Rechtstexte sind zu umfangreich. Bitte kürze die Texte.') + await updateDocument(config.collections.shopConfig, shop.id, { rechtstexte: json }) + return { ...aktuell, rechtstexte: { entwurf: rechtstexte, bestaetigt: aktuell.rechtstexte.bestaetigt } } + }) + leereCache(shop.siteKey) + await protokoll(req, 'Rechtstexte-Entwurf gespeichert') + res.json(rechtstexteAntwort(neu)) +})) + +router.post('/rechtstexte/bestaetigen', requireSession, nurKunde, limitWrites, route(async (req, res) => { + const { shop } = await mitShop(req, { schreiben: true }) + const hash = typeof req.body?.hash === 'string' ? req.body.hash : '' + const von = personLabel(person(req)) + const neu = await withLocks([`shop-rt:${shop.id}`], async () => { + const aktuell = await frischerShop(shop.id) + const { entwurf } = aktuell.rechtstexte + if (hash === '' || hash !== rechtstexteHash(entwurf, aktuell.einstellungen)) { + throw stop(409, 'Die Texte wurden inzwischen geändert. Bitte lies sie noch einmal durch und bestätige dann erneut.', 'texte_geaendert') + } + if (!vollstaendig(entwurf)) throw stop(400, 'Bitte fülle AGB, Widerrufsbelehrung, Widerrufsformular, Impressum und Datenschutz aus, bevor du bestätigst.') + const probleme = pruefeBestaetigbar(entwurf, { widerrufUrl: widerrufUrl(aktuell.siteKey) }) + if (probleme.length) { + throw Object.assign(stop(400, 'Die Texte können noch nicht bestätigt werden. Bitte behebe zuerst die genannten Punkte.', 'nicht_bestaetigbar'), { body: { code: 'nicht_bestaetigbar', probleme } }) + } + const at = new Date().toISOString() + const bestaetigt = { texte: { ...entwurf }, hash, at, von } + // Fassung ins Archiv (G09 zeigt dem Kaeufer die Fassung seiner Bestellung); scheitert das, wird nichts bestaetigt + await archiviereRechtstexte({ + shopId: shop.id, + customerId: shop.customerId, + hash, + texte: { ...entwurf, versandUndZahlung: versandUndZahlung(aktuell.einstellungen), gewaehrleistung: gewaehrleistungsHinweis() }, + at, + von, + }) + const json = JSON.stringify({ entwurf, bestaetigt }) + if (json.length > PRODUKT_DOC_MAX_JSON) throw stop(400, 'Die Rechtstexte sind zu umfangreich. Bitte kürze die Texte.') + await updateDocument(config.collections.shopConfig, shop.id, { + rechtstexte: json, + rechtstexteBestaetigtAt: at, + rechtstexteBestaetigtVon: von, + rechtstexteHash: hash, + }) + return { ...aktuell, rechtstexte: { entwurf, bestaetigt } } + }) + leereCache(shop.siteKey) + res.json(rechtstexteAntwort(neu)) +})) + +// ---- Shop oeffnen und schliessen + +router.post('/oeffnen', requireSession, nurKunde, limitWrites, route(async (req, res) => { + if (typeof req.body?.offen !== 'boolean') throw stop(400, 'Bitte wähle, ob der Shop geöffnet oder geschlossen sein soll.') + const { shop, doc } = await mitShop(req, { schreiben: true }) + const produkte = await listeProdukte(shop.id) + const ctx = { doc, shop, project: null } + if (req.body.offen) { + const stand = await bereitschaft(ctx, { produkte }) + if (!stand.bereit) throw Object.assign(stop(409, 'Der Shop kann noch nicht geöffnet werden. Bitte erledige zuerst die offenen Punkte.', 'nicht_bereit'), { body: { code: 'nicht_bereit', punkte: stand.punkte } }) + } + await updateDocument(config.collections.shopConfig, shop.id, { geoeffnet: req.body.offen, geoeffnetAt: new Date().toISOString() }) + leereCache(shop.siteKey) + const neu = { ...shop, geoeffnet: req.body.offen } + res.json({ schalter: req.body.offen, geoeffnet: await istGeoeffnet({ doc, shop: neu, project: null }, { produkte }), bereitschaft: await bereitschaft({ doc, shop: neu, project: null }, { produkte }) }) +})) + +// ---- Stripe-Konto (D2) + +/** Nur der Kunde selbst richtet sein Stripe-Konto ein (weder Kundenansicht noch "Ansehen als"). */ +const nurKundeStripe = (req, res, next) => { + if (isKundenansicht(req.session) || req.session?.viewAs) return res.status(403).json({ error: MELDUNG.stripeKundenansicht }) + return next() +} + +/** Stripe-Aufruf: jeder Fehler von Stripe wird zu 502 mit festem Text (Stripe-Texte verlassen den Server nie, geloggt wird nur Typ und Code). */ +async function ueberStripe(fn) { + try { + return await fn() + } catch (err) { + if (err?.status >= 400 && err.status < 500 && !err.type) throw err + console.error(`[shop] Stripe ${String(err?.type ?? err?.name ?? 'Fehler').slice(0, 60)} ${String(err?.code ?? '').slice(0, 60)}`.trim()) + throw stop(502, MELDUNG.stripeFern) + } +} + +const speichereStripe = async (shop, felder) => { + await updateDocument(config.collections.shopConfig, shop.id, felder) + leereCache(shop.siteKey) +} + +const stripeRueckUrl = (projectId, was) => `${config.publicBaseUrl}/dashboard.html#webshop?stripe=${was}&projekt=${encodeURIComponent(projectId)}` +const onboardingFuer = (accountId, projectId) => + ueberStripe(() => onboardingLink(accountId, { returnUrl: stripeRueckUrl(projectId, 'zurueck'), refreshUrl: stripeRueckUrl(projectId, 'neu') })) + +// Konto anlegen (einmalig, unter Sperre) und Onboarding-Link liefern +router.post('/stripe/einrichten', requireSession, nurKundeStripe, limitWrites, route(async (req, res) => { + const { shop, customerId, projectId, project } = await mitShop(req, { schreiben: true, zustaende: ['setup', 'active'] }) + const accountId = await withLocks([`shop-stripe:${shop.id}`], async () => { + const frisch = await frischerShop(shop.id) + if (frisch.stripeAccountId) return frisch.stripeAccountId + const customer = await getDocument(config.collections.customers, customerId).catch(() => null) + const neu = await ueberStripe(() => kontoAnlegen({ customer: customer || { $id: customerId, email: req.session?.email }, project })) + let status + try { + status = await ueberStripe(() => kontoStatus(neu)) + } catch { + status = statusAusKonto({}) + } + await speichereStripe(shop, { stripeAccountId: neu, stripeStatus: JSON.stringify(status) }) + return neu + }) + res.json({ url: await onboardingFuer(accountId, projectId) }) +})) + +// Neuer Onboarding-Link (der alte laeuft nach wenigen Minuten ab) +router.post('/stripe/link', requireSession, nurKundeStripe, limitWrites, route(async (req, res) => { + const { shop, projectId } = await mitShop(req, { schreiben: true, zustaende: ['setup', 'active'] }) + if (!shop.stripeAccountId) throw stop(409, MELDUNG.keinKonto, 'kein_konto') + res.json({ url: await onboardingFuer(shop.stripeAccountId, projectId) }) +})) + +// Status bei Stripe neu lesen und speichern +router.post('/stripe/aktualisieren', requireSession, nurKundeStripe, limitWrites, route(async (req, res) => { + const { shop } = await mitShop(req, { schreiben: true, zustaende: ['setup', 'active'] }) + if (!shop.stripeAccountId) throw stop(409, MELDUNG.keinKonto, 'kein_konto') + const status = await ueberStripe(() => kontoStatus(shop.stripeAccountId)) + const alt = shop.stripeStatus + const zahlungsarten = Array.isArray(shop.einstellungen?.zahlungsarten) ? shop.einstellungen.zahlungsarten : ['card'] + const behalteHinweis = typeof alt?.hinweis === 'string' && alt.hinweis !== '' && status.charges_enabled === true && zahlungsarten.some((z) => z === 'klarna' || z === 'sepa') + const neu = behalteHinweis ? { ...status, hinweis: alt.hinweis } : status + await speichereStripe(shop, { stripeStatus: JSON.stringify(neu) }) + res.json({ stripe: stripeAnsicht({ ...shop, stripeStatus: neu }) }) +})) + +// ---- Bestellungen und Widerrufe (E3) + +const seiteVon = (wert) => { + const nr = Number.parseInt(wert, 10) + return Number.isInteger(nr) && nr >= 1 ? nr : 1 +} +const textParam = (wert) => (typeof wert === 'string' ? wert : undefined) +/** Status aendern verschickt Mails an Kaeufer und erstattet Geld: nie in der Kundenansicht oder "Ansehen als". */ +const nurHaendler = nurKunde +/** Bestellungen bearbeiten, erstatten und Widerrufe abhaken muss auch bei deaktivierter Funktion gehen (Widerruf, Erstattung bleiben Pflicht). */ +const ZUSTAENDE_ABWICKLUNG = ['setup', 'active', 'disabled'] + +// Liste (Lesen auch in der Kundenansicht und bei deaktiviertem Shop) +router.get('/bestellungen', requireSession, route(async (req, res) => { + const { shop } = await mitShop(req) + res.json(await listeBestellungen(shop.id, { status: textParam(req.query.status), suche: textParam(req.query.suche), seite: seiteVon(req.query.seite) })) +})) + +// CSV fuer die Buchhaltung (vor /bestellungen/:id, damit der Pfad eindeutig bleibt) +router.get('/bestellungen.csv', requireSession, limitCsv, route(async (req, res) => { + const { shop } = await mitShop(req) + const erg = await bestellungenCsv(shop.id, { von: textParam(req.query.von), bis: textParam(req.query.bis) }) + res.set({ + 'Content-Type': 'text/csv; charset=utf-8', + 'Content-Disposition': `attachment; filename="${erg.dateiname}"`, + 'X-Content-Type-Options': 'nosniff', + }) + res.send(erg.csv) +})) + +router.get('/bestellungen/:id', requireSession, route(async (req, res) => { + const { shop } = await mitShop(req) + const order = await bestellungLesen(req.params.id) + if (!order || order.shopId !== shop.id) throw stop(404, 'Diese Bestellung gibt es nicht.') + res.json({ bestellung: bestellungKunde(order) }) +})) + +router.post('/bestellungen/:id/aktion', requireSession, nurHaendler, limitWrites, route(async (req, res) => { + const { shop, doc, project } = await mitShop(req, { schreiben: true, zustaende: ZUSTAENDE_ABWICKLUNG }) + const aktion = typeof req.body?.aktion === 'string' ? req.body.aktion : '' + const { order, mailOk } = await setzeStatus({ doc, shop, project }, req.params.id, aktion, { + versanddienst: req.body?.versanddienst, + sendungsnummer: req.body?.sendungsnummer, + }) + res.json({ bestellung: bestellungKunde(order), mailOk }) +})) + +router.post('/bestellungen/:id/erstatten', requireSession, nurHaendler, limitWrites, route(async (req, res) => { + const { shop, doc, project } = await mitShop(req, { schreiben: true, zustaende: ZUSTAENDE_ABWICKLUNG }) + const { order, erstattung, voll, mailOk } = await erstatte({ doc, shop, project }, req.params.id, { + betragCent: req.body?.betragCent, + grund: req.body?.grund, + }) + res.json({ bestellung: bestellungKunde(order), erstattung, voll, mailOk }) +})) + +router.get('/widerrufe', requireSession, route(async (req, res) => { + const { shop } = await mitShop(req) + res.json(await listeWiderrufe(shop.id, { seite: seiteVon(req.query.seite) })) +})) + +router.post('/widerrufe/:id/erledigt', requireSession, nurHaendler, limitWrites, route(async (req, res) => { + const { shop } = await mitShop(req, { schreiben: true, zustaende: ZUSTAENDE_ABWICKLUNG }) + res.json({ widerruf: await markiereBearbeitet(shop.id, req.params.id) }) +})) + +// ---- Bilder + +// Bild als rohe Bytes (application/octet-stream), hoechstens 8 MB; die Pruefung des Shops laeuft vor dem Einlesen +router.post( + '/bild', + requireSession, + limitWrites, + mitShopSchreibend, + (req, res, next) => { + if (bildLimitErreicht(getSessionCustomerId(req))) return res.status(429).json({ error: MELDUNG.zuVieleBilder }) + return next() + }, + uploadSlot, + express.raw({ type: 'application/octet-stream', limit: BILD_MAX_BYTES }), + route(async (req, res) => { + const { shop, customerId } = req.shopCtx + const bild = await speichereBild({ customerId, shopId: shop.id, siteKey: shop.siteKey, buffer: req.body }) + await protokoll(req, 'Bild hochgeladen') + res.status(201).json({ imageId: bild.imageId, w: bild.w, h: bild.h }) + }) +) + +// Vorschau im Portal: nur fuer den Kunden, dem das Projekt gehoert +router.get('/bild/:projectId/:datei', requireSession, route(async (req, res) => { + const { shop } = await mitShop(req) + const treffer = BILD_DATEI_RE.exec(req.params.datei) + const datei = treffer && (await bildMeta(shop.siteKey, treffer[1])) && bildPfad(shop.siteKey, treffer[1], treffer[2]) + if (!datei) return res.status(404).json({ error: MELDUNG.bildFehlt }) + res.set({ 'Cache-Control': 'private, max-age=3600', 'X-Content-Type-Options': 'nosniff' }) + return res.sendFile(datei, (err) => { + if (err && !res.headersSent) res.status(404).json({ error: MELDUNG.bildFehlt }) + }) +})) + +// Fehler beim Einlesen eines Uploads (zu gross, abgebrochen): verstaendlich beantworten, nie Rohtext +router.use((err, _req, res, next) => { + if (err?.type === 'entity.too.large') return res.status(413).json({ error: 'Das Bild ist größer als 8 MB.' }) + if (err?.status >= 400 && err.status < 500 && res.headersSent === false) return res.status(err.status).json({ error: 'Die Anfrage ist ungültig.' }) + return next(err) +}) + +export default router diff --git a/server/routes/shopSeite.js b/server/routes/shopSeite.js new file mode 100644 index 0000000..4503411 --- /dev/null +++ b/server/routes/shopSeite.js @@ -0,0 +1,62 @@ +import { readFileSync } from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { Router } from 'express' + +/** + * Oeffentliche Standalone-Seiten des Webshops (Plan 2026-10-05-webshop.md, H2; Spec 5.4): + * GET /shop/:siteKey Shop mit dem Widget public/embed/shop.js + * GET /shop/:siteKey/widerruf dieselbe Seite, das Widget oeffnet gleich die Widerrufsfunktion + * Die Seite verraet nicht, ob der Schluessel existiert oder der Shop aktiv ist: jeder syntaktisch gueltige Schluessel + * bekommt dieselbe Seite, ob der Shop laeuft entscheidet das Widget ueber die oeffentliche Schnittstelle. Alles andere ist 404. + */ + +const SITE_KEY_RE = /^[a-z2-7]{16}$/ +const DATEI = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', '..', 'public', 'shop-seite.html') +const CSP = [ + "default-src 'self'", + "img-src 'self' data:", + "script-src 'self'", + "style-src 'self' 'unsafe-inline'", // das Widget legt ein +
      Töpferei Brandt – Website des Händlers (Testseite)

      Unser Online-Shop

      +

      Unser Shop ist gerade nicht erreichbar. Bestellungen telefonisch unter 06302 1234567.

      +
      Fußbereich
      ` + +async function neuerKontext(browser, { cookie = true, viewport = DESKTOP } = {}) { + // Service Worker des Portals blockieren: ihre Anfragen umgingen page.route + const ctx = await browser.newContext({ viewport, serviceWorkers: 'block' }) + if (cookie) await ctx.addCookies([await srv.portalCookie()]) + ctx.setDefaultTimeout(10000) + // Portal (https://project.webklar.test): echte Router des Testservers; die uebrigen Portal-Schnittstellen beantwortet portalHilfe + await ctx.route(`${srv.portalUrl}/**`, async (route) => { + const req = route.request() + const url = new URL(req.url()) + const json = (body, status = 200) => route.fulfill({ status, contentType: 'application/json', body: JSON.stringify(body) }) + if (url.pathname.startsWith('/api/') && !ECHT.some((re) => re.test(url.pathname))) return portalHilfe(url.pathname, json) + const headers = await req.allHeaders() + delete headers.host + const antwort = await route.fetch({ url: `${srv.url}${url.pathname}${url.search}`, headers }) + return route.fulfill({ response: antwort }) + }) + // Website des Haendlers: ihre Seiten liefert das Skript (jede Adresse dieselbe Seite, mit oder ohne Rueckkehr-Parameter) + await ctx.route(`${srv.kundenUrl}/**`, async (route) => { + if (new URL(route.request().url()).pathname === '/favicon.ico') return route.fulfill({ status: 404, body: '' }) + return route.fulfill({ status: 200, contentType: 'text/html; charset=utf-8', body: kundenSeite(srv.siteKey()) }) + }) + // Stripe: Navigation abfangen (Haendler-Onboarding -> zurueck ins Portal; Kasse -> Platzhalterseite, der Test navigiert selbst zurueck) + await ctx.route(/^https:\/\/[a-z]+\.stripe\.test\//, async (route) => { + const url = route.request().url() + const host = new URL(url).host + if (host === 'connect.stripe.test') { + stripeNavigationen.push({ art: 'connect', url }) + const zurueck = `${srv.portalUrl}/dashboard.html#webshop?stripe=zurueck&projekt=${srv.PROJEKT}` + return route.fulfill({ status: 200, contentType: 'text/html', body: `Stripe (Nachbau)

      Onboarding

      ` }) + } + stripeNavigationen.push({ art: host === 'checkout.stripe.test' ? 'checkout' : 'sonst', url }) + return route.fulfill({ status: 200, contentType: 'text/html', body: 'Stripe-Kasse (Nachbau)

      Stripe-Kasse (Nachbau)

      ' }) + }) + ctx.on('page', (seite) => { + offeneSeiten.add(seite) + seite.on('close', () => offeneSeiten.delete(seite)) + seite.on('pageerror', (e) => schritt?.fehler.push(`JavaScript-Fehler: ${e.message}`)) + seite.on('dialog', (d) => { schritt?.fehler.push(`Browser-Dialog ${d.type()}: ${d.message()}`); d.dismiss().catch(() => {}) }) + seite.on('request', (r) => { try { const u = new URL(r.url()); if (u.protocol.startsWith('http') && !u.host.endsWith('.stripe.test')) fremdeHosts.add(u.host) } catch { /* data: u. a. */ } }) + seite.on('console', (m) => { if (m.type() === 'error' && !/Failed to load resource/.test(m.text())) schritt?.hinweise.push(`Konsole: ${m.text().slice(0, 160)}`) }) + }) + return ctx +} + +let browser = null +async function lauf(name, fn) { + if (process.env.NUR && !name.includes(process.env.NUR)) return + schritt = { name, fehler: [], hinweise: [] } + console.log(`--- ${name}`) + try { + await fn() + } catch (e) { + schritt.fehler.push(`Abbruch: ${String(e.message).split('\n')[0]}`) + if (process.env.STACK) console.log(e.stack) + } + if (schritt.fehler.length) { + let i = 0 + for (const seite of offeneSeiten) { + i += 1 + await seite.evaluate(() => window.scrollTo(0, 0)).catch(() => {}) + const hoehe = await seite.evaluate(() => Math.min(Math.max(document.documentElement.scrollHeight, window.innerHeight), 3600)).catch(() => 900) + await seite.setViewportSize({ width: 1280, height: hoehe }).catch(() => {}) + await seite.screenshot({ path: `${SHOTS}/fehler-${name}-${i}.png` }).catch(() => {}) + await seite.setViewportSize(DESKTOP).catch(() => {}) + } + } + const bestanden = schritt.fehler.length === 0 + ergebnisse.push([name, bestanden, schritt.fehler, schritt.hinweise]) + console.log(`${bestanden ? 'ok ' : 'FAIL'} ${name}${bestanden ? '' : `: ${schritt.fehler.join(' | ')}`}`) + for (const h of schritt.hinweise) console.log(` ! ${h}`) +} + +const oeffne = (page, hash) => page.goto(`${srv.portalUrl}/dashboard.html${hash}`) +const webshopBereit = async (page) => { + await page.locator('#view-webshop.active').waitFor() + await page.locator('#webshop-body :is(.sh-reiterleiste, .sh-anfrage, .sh-anfrage-karte, .tm-bestellen, .sh-info)').first().waitFor() +} +const reiter = async (page, key) => { + await page.locator(`[data-reiter="${key}"]`).click() + await page.locator(`#sh-tab-${key}[aria-selected="true"]`).waitFor() +} +const meldung = (page) => page.locator('.sh-meldung') +const warteMeldung = async (page, teil) => { + await warteBis(async () => enthaelt(await meldung(page).innerText().catch(() => ''), teil), `Meldung „${teil}“`) +} +/** Artikel speichern und warten, bis der Server n Artikel hat; sonst die Feldfehler des Editors melden. */ +async function speichereArtikel(page, form, n) { + await form.locator('[data-aktion="speichern"]').click() + const ende = Date.now() + 10000 + while (srv.produkte().length < n && Date.now() < ende) { + const fehler = await page.locator('[data-fehler]:not(:empty)').allInnerTexts() + if (fehler.length) throw new Error(`Artikel ${n} nicht gespeichert, Feldfehler: ${fehler.join(' / ')}`) + await new Promise((fertig) => { setTimeout(fertig, 100) }) + } + ok(srv.produkte().length === n, `${n}. Artikel gespeichert (${srv.produkte().length})`) +} +const echteMeldungen = async (page) => norm(await meldung(page).innerText().catch(() => '')) + +// ---------------------------------------------------------------- Szenario + +let portalPage = null // Haendler (mit Cookie) +let ctxHaendler = null +const merk = {} // Zahlen und IDs zwischen den Schritten + +async function szenario() { + ctxHaendler = await neuerKontext(browser) + portalPage = await ctxHaendler.newPage() + const page = portalPage + + // ------------------------------------------------------------ 1 + await lauf('1_kunde_kachel_und_anfrage', async () => { + await oeffne(page, '#funktionen') + const kachel = page.locator('[data-fn-tile="shop"]') + await kachel.waitFor() + ok(enthaelt(await kachel.innerText(), 'Webshop'), 'Kachel Webshop sichtbar') + ok(enthaelt(await kachel.innerText(), '299 €'), 'Kachel nennt den Preis 299 €') + ok(await page.locator('[data-fn-tile="shop"] .fn-tile-cta').innerText().then((t) => enthaelt(t, 'Webshop anfragen')), 'Knopf „Webshop anfragen“') + await shot(page, '01-kachel-webshop') + await kachel.locator('.fn-tile-cta').click() + await webshopBereit(page) + const form = page.getByRole('form', { name: 'Webshop anfragen' }) + await form.waitFor() + await shot(page, '02-anfrage-formular') + await form.locator('[data-feld="warenart"]').selectOption(ANFRAGE.warenart) + await form.locator('[data-feld="beschreibung"]').fill(ANFRAGE.beschreibung) + await form.locator('[data-feld="artikelzahl"]').selectOption(ANFRAGE.artikelzahl) + await form.locator('[data-feld="versand"]').check() + await form.locator('[data-feld="abholung"]').check() + await form.locator('[data-feld="unternehmer"]').check() + await form.locator('[data-feld="keineAusgeschlossenen"]').check() + await form.locator('[data-feld="nachricht"]').fill(ANFRAGE.nachricht) + await form.locator('[data-aktion="anfrage-senden"]').click() + const karte = page.locator('[data-anfrage="offen"]') + await karte.waitFor() + ok(enthaelt(await karte.innerText(), 'Anfrage wird geprüft'), 'Karte „Deine Anfrage wird geprüft“') + ok(enthaelt(await karte.innerText(), 'Anfrage zurückziehen'), 'Knopf „Anfrage zurückziehen“') + await shot(page, '03-anfrage-wird-geprueft') + ok(srv.shopDoc()?.anfrageStatus === 'offen', `shopConfig.anfrageStatus offen (ist ${srv.shopDoc()?.anfrageStatus})`) + await srv.warteAufVersand() + ok(srv.mails('Webshop').length >= 1, `Team-Mail zur Anfrage (T21) verschickt (${srv.mails().map((m) => m.subject).join(' / ')})`) + }) + + // ------------------------------------------------------------ 2 + await lauf('2_annahme_bestellseite_zahlung_freischaltung', async () => { + const r = await srv.entscheide('annehmen') + ok(r.anfrage.status === 'angenommen', `Anfrage angenommen (${r.anfrage.status}), mailFehler ${r.mailFehler}`) + ok(srv.mails('Webshop').some((m) => m.to.some((t) => t.address === KUNDEN_EMAIL)), 'K24 an den Kunden') + await page.reload() + await webshopBereit(page) + const seite = page.locator('.tm-bestellen') + await seite.waitFor() + const haken = await seite.locator('input[data-haken]').count() + ok(haken === 5, `fünf Haken auf der Bestellseite (${haken})`) + const knopf = seite.locator('[data-aktion="bestellen"]') + ok(norm(await knopf.innerText()) === 'Zahlungspflichtig bestellen · 299 €', `Knopf „${norm(await knopf.innerText())}“`) + ok(await knopf.isDisabled(), 'Knopf gesperrt, solange Haken fehlen') + await shot(page, '04-bestellseite') + for (const h of await seite.locator('input[data-haken]').all()) await h.check() + ok(await knopf.isEnabled(), 'Knopf frei nach allen Haken') + await knopf.click() + await warteBis(() => stripeNavigationen.some((n) => n.art === 'checkout'), 'Navigation zur Stripe-Kasse') + const nav = stripeNavigationen.filter((n) => n.art === 'checkout').at(-1) + ok(/^https:\/\/checkout\.stripe\.test\/c\/pay\/cs_/.test(nav.url), `Weiterleitung zur Kasse (${nav.url})`) + ok(srv.featureDoc()?.state === 'ordered', `Funktion ordered (${srv.featureDoc()?.state})`) + ok(srv.aw.docs('featureConsents').size === 1, `Zustimmungsnachweis gespeichert (${srv.aw.docs('featureConsents').size})`) + + // Zahlung: signierter Plattform-Webhook + const w = await srv.bezahleEinrichtung() + ok(w.status === 200, `Plattform-Webhook ${w.status} ${JSON.stringify(w.body)}`) + ok(srv.featureDoc()?.state === 'setup', `Funktion setup nach Zahlung (${srv.featureDoc()?.state})`) + ok(Boolean(srv.featureDoc()?.ticketId), 'Einrichtungs-Ticket angelegt') + ok(srv.siteKey() && /^[a-z2-7]{16}$/.test(srv.siteKey()), `siteKey ${srv.siteKey()}`) + // zurueck im Portal: „Danke!“ und Hinweis Einrichtung + await oeffne(page, `#webshop?bestellt=1&projekt=${srv.PROJEKT}`) + await page.reload() + await webshopBereit(page) + await page.locator('.sh-reiterleiste').waitFor() + ok(await page.locator('[data-hinweis="setup"]').count() === 1, 'Hinweis „Wir bauen deinen Shop gerade ein“') + await srv.schliesseTicket() + await page.reload() + await page.locator('.sh-reiterleiste').waitFor() + await warteBis(() => srv.featureDoc()?.state === 'active', 'Funktion active nach geschlossenem Ticket') + ok(srv.featureDoc()?.state === 'active', 'Funktion active') + await page.reload() + await page.locator('.sh-reiterleiste').waitFor() + ok(await page.locator('[data-hinweis="setup"]').count() === 0, 'Hinweis Einrichtung weg') + await shot(page, '05-shop-aktiv-uebersicht') + }) + + // ------------------------------------------------------------ 3a Einstellungen + await lauf('3a_einstellungen', async () => { + await reiter(page, 'einstellungen') + const form = page.getByRole('form', { name: 'Einstellungen des Webshops' }) + await form.waitFor() + await shot(page, '06-einstellungen-leer') + const f = (n) => form.locator(`[data-feld="${n}"]`) + await f('shopname').fill('Töpferei Brandt') + await f('akzent').fill('#0f766e') + await f('anbieter.name').fill('Töpferei Brandt, Inh. Hanna Brandt') + await f('anbieter.strasse').fill('Hauptstraße 12') + await f('anbieter.plz').fill('67722') + await f('anbieter.ort').fill('Winnweiler') + await f('anbieter.email').fill(KUNDEN_EMAIL) + await f('anbieter.telefon').fill('06302 1234567') + await f('anbieter.ustId').fill('DE123456789') + await form.locator('input[name="sh-kleinunternehmer"][value="nein"]').check() + await f('versand.laender').selectOption('DE,AT') + await f('versand.kostenCent.DE').fill('4,90') + await f('versand.kostenCent.AT').fill('7,90') + await f('versand.freiAbCent').fill('80,00') + await f('abholung.aktiv').check() + await f('abholung.adresse').fill('Hauptstraße 12, 67722 Winnweiler') + await f('abholung.hinweis').fill('Di bis Fr 10 bis 17 Uhr') + await f('lieferzeit').fill('2–4 Werktage') + await shot(page, '08-einstellungen-ausgefuellt') + await form.locator('[data-aktion="einstellungen-speichern"]').click() + await warteMeldung(page, 'Die Einstellungen sind gespeichert') + const e = srv.einstellungen() + ok(e.kleinunternehmer === false, `Steuerart gespeichert: kleinunternehmer ${e.kleinunternehmer}`) + ok(e.anbieter?.ort === 'Winnweiler' && e.shopname === 'Töpferei Brandt', 'Einstellungen im Dokument') + ok(e.versand?.kostenCent?.AT === 790 && e.versand?.freiAbCent === 8000, `Versandkosten ${JSON.stringify(e.versand?.kostenCent)} frei ab ${e.versand?.freiAbCent}`) + ok(e.zahlungsarten?.includes('card'), 'Karte als Zahlungsart') + }) + + // ------------------------------------------------------------ 3b Rechtliches + await lauf('3b_rechtliches', async () => { + await reiter(page, 'rechtliches') + const form = page.getByRole('form', { name: 'Rechtstexte des Webshops' }) + await form.waitFor() + await shot(page, '09-rechtliches-leer') + for (const key of ['agb', 'widerrufsbelehrung', 'widerrufsformular']) await form.locator(`[data-aktion="muster-${key}"]`).click() + const platzhalter = [] + for (const key of ['agb', 'widerrufsbelehrung', 'widerrufsformular']) { + const feld = form.locator(`[data-text="${key}"]`) + const wert = await feld.inputValue() + ok(wert.length > 200, `Muster ${key} eingesetzt (${wert.length} Zeichen)`) + const treffer = wert.match(/\[[^\]]+\]/g) || [] + for (const t of treffer) platzhalter.push(`${key}: ${t}`) + } + merk.platzhalter = platzhalter + // Platzhalter ersetzen und die Muster-Kopfzeile des AGB entfernen + const ersatz = (t) => (/datum|ort/i.test(t) ? 'Winnweiler' : /name|unterschrift/i.test(t) ? 'Hanna Brandt' : /anschrift|adresse/i.test(t) ? 'Hauptstraße 12, 67722 Winnweiler' : /mail/i.test(t) ? KUNDEN_EMAIL : /waren|dienstleistung|bestellt/i.test(t) ? 'Tasse Seladon' : 'Töpferei Brandt') + for (const key of ['agb', 'widerrufsbelehrung', 'widerrufsformular']) { + const feld = form.locator(`[data-text="${key}"]`) + let wert = await feld.inputValue() + wert = wert.replace(/\[[^\]]+\]/g, ersatz).replace(/^Muster – bitte prüfen und anpassen\n*/m, '') + await feld.fill(wert) + } + await form.locator('[data-text="impressumUrl"]').fill('https://www.toepferei-brandt.example/impressum') + await form.locator('[data-text="datenschutzUrl"]').fill('https://www.toepferei-brandt.example/datenschutz') + // ungespeichert: „Erst speichern“ + const bestaetigen = page.locator('[data-aktion="bestaetigen"]') + ok(norm(await bestaetigen.innerText()) === 'Erst speichern', `Knopf vor dem Speichern „${norm(await bestaetigen.innerText())}“`) + await form.locator('[data-aktion="entwurf-speichern"]').click() + await warteMeldung(page, 'Der Entwurf ist gespeichert') + ok(Boolean(srv.rechtstexte().entwurf?.agb), 'Entwurf im Dokument') + const probleme = await page.locator('[data-probleme] li').allInnerTexts() + ok(probleme.length === 0, `keine Probleme vor dem Bestaetigen (${probleme.join(' / ')})`) + ok(await page.locator('[data-lesetext="gewaehrleistung-bild"]').count() === 1, 'Gewährleistungs-Bild im Reiter') + await shot(page, '10-rechtliches-entwurf') + await page.locator('[data-feld="bestaetigung"]').check() + ok(norm(await bestaetigen.innerText()) === 'Texte bestätigen' && await bestaetigen.isEnabled(), 'Knopf „Texte bestätigen“ frei') + await bestaetigen.click() + await warteMeldung(page, 'Die Texte sind bestätigt') + ok(Boolean(srv.rechtstexte().bestaetigt?.hash), 'Bestätigung gespeichert') + ok(srv.aw.docs('shopRechtstexteArchiv').size === 1, `Archiv der bestätigten Fassung (${srv.aw.docs('shopRechtstexteArchiv').size})`) + ok(await page.locator('[data-status="bestaetigt"]').count() === 1, 'Status „Bestätigt“ sichtbar') + await shot(page, '11-rechtliches-bestaetigt') + }) + + // ------------------------------------------------------------ 3c Artikel + await lauf('3c_artikel', async () => { + await reiter(page, 'artikel') + await page.locator('[data-aktion="artikel-neu"]').click() + const form = page.getByRole('form', { name: 'Artikel anlegen' }) + await form.waitFor() + const f = (n) => form.locator(`[data-feld="${n}"]`) + // Artikel 1: Tasse mit Bild + await f('name').fill('Tasse Seladon') + await f('kategorie').fill('Geschirr') + await f('kurz').fill('Handgedrehte Tasse mit seladongrüner Glasur.') + await f('beschreibung').fill('Fasst etwa 300 ml. Spülmaschinengeeignet. Jedes Stück ist ein Unikat.') + await f('preisCent').fill('18,50') + await f('bestand').fill('12') + const png = await sharp({ create: { width: 800, height: 800, channels: 3, background: { r: 120, g: 170, b: 150 } } }) + .composite([{ input: Buffer.from(''), top: 0, left: 0 }]) + .png().toBuffer() + await form.locator('input[type="file"]').setInputFiles({ name: 'tasse.png', mimeType: 'image/png', buffer: png }) + await page.locator('li[data-bild]').waitFor() + ok(await page.locator('li[data-bild] img').evaluate((img) => img.complete && img.naturalWidth > 0), 'Bild hochgeladen, Vorschau geladen') + await shot(page, '12-artikel-editor-tasse', { telefon: false }) + await speichereArtikel(page, form, 1) + // Artikel 2: Vase mit Varianten + await page.locator('[data-aktion="artikel-neu"]').click() + const form2 = page.getByRole('form', { name: 'Artikel anlegen' }) + await form2.waitFor() + const g = (n) => form2.locator(`[data-feld="${n}"]`) + await g('name').fill('Vase Wabi') + await g('kategorie').fill('Vasen') + await g('kurz').fill('Bauchige Vase in drei Größen.') + await g('beschreibung').fill('Aus Steinzeug, innen glasiert, wasserdicht.') + await g('variantenAn').check() + await g('varianten.name').fill('Größe') + const werte = [['Klein', '24,00', '5'], ['Mittel', '36,00', '3'], ['Groß', '52,00', '2']] + for (let i = 0; i < werte.length; i += 1) { + if (i > 0) await form2.locator('[data-aktion="wert-neu"]').click() + await g(`varianten.werte.${i}.name`).fill(werte[i][0]) + await g(`varianten.werte.${i}.preisCent`).fill(werte[i][1]) + await g(`varianten.werte.${i}.bestand`).fill(werte[i][2]) + } + await shot(page, '13-artikel-editor-vase', { telefon: false }) + await speichereArtikel(page, form2, 2) + const ps = srv.produkte() + ok(ps.length === 2, `zwei Artikel gespeichert (${ps.length})`) + const tasse = ps.find((p) => p.name === 'Tasse Seladon') + const vase = ps.find((p) => p.name === 'Vase Wabi') + ok(tasse?.produkt.preisCent === 1850 && tasse?.produkt.bilder?.length === 1, `Tasse: ${tasse?.produkt.preisCent} Cent, ${tasse?.produkt.bilder?.length} Bild`) + ok(vase?.produkt.varianten?.werte?.length === 3, `Vase: ${vase?.produkt.varianten?.werte?.length} Varianten`) + ok(await page.locator('li[data-artikel]').count() === 2, 'zwei Artikel in der Liste') + await shot(page, '14-artikel-liste') + }) + + // ------------------------------------------------------------ 3d Uebersicht, Stripe, Shop oeffnen + await lauf('3d_uebersicht_stripe_oeffnen', async () => { + await reiter(page, 'uebersicht') + await page.locator('[data-liste="bereitschaft"]').waitFor() + ok(await page.locator('li[data-ok="0"]').count() > 0, 'Checkliste: noch offene Punkte vor Stripe') + const oeffnen = page.locator('[data-aktion="oeffnen"]') + ok(await oeffnen.isDisabled(), '„Shop öffnen“ gesperrt') + await shot(page, '15-uebersicht-vor-stripe') + const vorher = stripeNavigationen.length + await page.locator('[data-aktion="stripe-einrichten"]').click() + await warteBis(() => stripeNavigationen.length > vorher && stripeNavigationen.at(-1).art === 'connect', 'Weiterleitung zum Stripe-Onboarding') + ok(/^https:\/\/connect\.stripe\.test\/setup\/acct_/.test(stripeNavigationen.at(-1).url), `Onboarding-Link ${stripeNavigationen.at(-1).url}`) + // zurueck ins Portal (Platzhalterseite leitet zu #webshop?stripe=zurueck&projekt=…) + await page.waitForURL(/dashboard\.html/) + await page.locator('.sh-reiterleiste').waitFor() + await page.locator('[data-liste="bereitschaft"]').waitFor() + await warteMeldung(page, 'Willkommen zurück von Stripe') + ok(!page.url().includes('stripe=zurueck'), `Rückkehr-Parameter aus der Adresse entfernt (${page.url()})`) + ok(await page.locator('[data-stripe="zahlungen"]').innerText().then((t) => enthaelt(t, 'noch nicht freigeschaltet')), 'Stripe: Zahlungen noch nicht freigeschaltet') + ok(srv.kontoId()?.startsWith('acct_'), `Konto angelegt (${srv.kontoId()})`) + await shot(page, '16-uebersicht-nach-rueckkehr-stripe') + srv.kontoFreigeben() + await page.locator('[data-aktion="stripe-aktualisieren"]').click() + await warteMeldung(page, 'aktuell') + await warteBis(async () => (await page.locator('li[data-ok="0"]').count()) === 0, 'alle Punkte der Checkliste erledigt') + ok(await page.locator('[data-stripe="zahlungen"]').innerText().then((t) => enthaelt(t, 'Zahlungen: aktiv')), 'Stripe: Zahlungen aktiv') + ok(await oeffnen.isEnabled(), '„Shop öffnen“ frei') + await shot(page, '17-uebersicht-bereit') + await oeffnen.click() + await warteBis(async () => (await page.locator('[data-zustand="offen"]').count()) === 1, 'Zustand „Dein Shop ist geöffnet“') + ok(srv.shopDoc()?.geoeffnet === true, 'geoeffnet im Dokument') + ok(await page.locator('[data-aktion="schliessen"]').count() === 1, 'Knopf „Shop schließen“') + await shot(page, '18-uebersicht-geoeffnet') + }) +} + +// ---------------------------------------------------------------- Kaeufer, Mails, Haendler, Widerruf + +async function kaeuferSchritte() { + const key = srv.siteKey() + const ctxK = await neuerKontext(browser, { cookie: false }) + const kaeufer = await ctxK.newPage() + + // ------------------------------------------------------------ 4a Standalone + await lauf('4a_kaeufer_seite_raster_detail_korb', async () => { + await kaeufer.goto(`${srv.portalUrl}/shop/${key}`) + await kaeufer.locator('.wks-raster li').first().waitFor() + ok(await kaeufer.locator('.wks-raster li').count() === 2, 'zwei Artikel im Raster') + ok(enthaelt(await kaeufer.locator('.wks-shopname').innerText(), 'Töpferei Brandt'), 'Shopname im Kopf') + ok(await kaeufer.locator('.wks-karte-bild').first().evaluate((img) => img.complete && img.naturalWidth > 0), 'Artikelbild der Tasse geladen') + ok(enthaelt(await kaeufer.locator('.wks-raster').innerText(), 'ab 24,00 €'), 'Vase „ab 24,00 €“') + ok(enthaelt(await kaeufer.locator('.wks-hinweiszeile').first().innerText(), 'Alle Preise inkl. MwSt.'), 'Steuerhinweis') + ok(await kaeufer.locator('.wks-preis-versand button').count() === 2, 'Versandhinweis „zzgl. Versand“ bei jedem Preis') + ok(await passtInBreite(kaeufer), 'Seite passt in die Breite (Desktop)') + await shot(kaeufer, '20-shop-raster') + await kaeufer.getByRole('button', { name: 'Tasse Seladon' }).first().click() + await kaeufer.locator('.wks-detail').waitFor() + ok(enthaelt(await kaeufer.locator('.wks-info').innerText(), '18,50 €'), 'Detail: Preis 18,50 €') + await shot(kaeufer, '21-shop-detail') + await kaeufer.getByRole('button', { name: 'In den Warenkorb' }).click() + await kaeufer.locator('.wks-info .wks-meldung:not([hidden])').waitFor() + await kaeufer.getByRole('button', { name: 'Weiter einkaufen' }).click() + await kaeufer.getByRole('button', { name: 'Vase Wabi' }).first().click() + await kaeufer.locator('.wks-detail').waitFor() + await kaeufer.getByRole('button', { name: 'In den Warenkorb' }).click() + ok(enthaelt(await kaeufer.locator('.wks-info').innerText(), 'Bitte wählen Sie eine Variante'), 'Ohne Variante: Hinweis „Bitte wählen Sie eine Variante“') + await kaeufer.locator('select.wks-eingabe').selectOption({ label: 'Mittel – 36,00 €' }) + ok(enthaelt(await kaeufer.locator('.wks-info .wks-preis').innerText(), '36,00 €'), 'Variantenpreis 36,00 €') + await shot(kaeufer, '22-shop-detail-variante', { telefon: false }) + await kaeufer.getByRole('button', { name: 'In den Warenkorb' }).click() + await kaeufer.locator('.wks-info .wks-meldung:not([hidden])').waitFor() + await kaeufer.getByRole('button', { name: 'Zum Warenkorb' }).click() + await kaeufer.locator('.wks-zeilen li').first().waitFor() + ok(await kaeufer.locator('.wks-zeilen li').count() === 2, 'zwei Positionen im Warenkorb') + await shot(kaeufer, '23-shop-warenkorb') + }) + + // ------------------------------------------------------------ 4b Kasse + await lauf('4b_kasse_pflichtangaben_und_bestellung', async () => { + await kaeufer.getByRole('button', { name: 'Zur Kasse' }).click() + await kaeufer.locator('.wks-pflicht .wks-summen').waitFor() + const pflicht = await kaeufer.locator('.wks-pflicht').innerText() + // § 312j BGB: wesentliche Merkmale, Gesamtpreis, Lieferung, Widerruf direkt vor dem Knopf + for (const teil of ['Ihre Bestellung', '1 × Tasse Seladon', '1 × Vase Wabi (Größe: Mittel)', 'Zwischensumme', 'Versandkosten', 'Gesamtsumme', 'Lieferung und Zahlung', 'Lieferzeit', '2–4 Werktage', 'Zahlungsarten', 'Anbieter', 'Töpferei Brandt', 'Widerrufsrecht', 'Sie haben ein gesetzliches Widerrufsrecht', 'Name, Lieferadresse und Zahlung geben Sie im nächsten Schritt bei unserem Zahlungsdienstleister Stripe ein']) { + if (!ok(enthaelt(pflicht, teil), `§ 312j-Block enthält „${teil}“`) && !merk.pflichtGezeigt) { merk.pflichtGezeigt = true; console.log(` Pflichtblock: ${norm(pflicht)}`) } + } + ok(enthaelt(await kaeufer.locator('.wks-summen').innerText(), '54,50 €'), `Zwischensumme 54,50 € (${norm(await kaeufer.locator('.wks-summen').innerText())})`) + ok(enthaelt(await kaeufer.locator('.wks-summen').innerText(), '59,40 €'), 'Gesamtsumme 59,40 € (inkl. 4,90 € Versand)') + // Gewaehrleistungshinweis mit der amtlichen Grafik + const gk = kaeufer.locator('.wks-gewaehr') + ok(await gk.count() === 1, 'Gewährleistungskasten vorhanden') + const img = gk.locator('img') + ok((await img.getAttribute('src'))?.endsWith('/img/gewaehrleistung-eu.webp'), `Gewährleistungsbild ${await img.getAttribute('src')}`) + ok(await img.evaluate((i) => i.complete && i.naturalWidth > 0), 'Gewährleistungsbild geladen') + ok(enthaelt((await img.getAttribute('alt')) || '', 'Gewährleistung'), 'Alternativtext des Bildes enthält den Hinweistext') + ok(enthaelt(await gk.innerText(), 'Ihre gesetzlichen Gewährleistungsrechte'), 'Überschrift des Kastens') + // AGB-Satz + const agb = norm(await kaeufer.locator('.wks-agb-hinweis').innerText()) + ok(agb === 'Es gelten unsere AGB. Informationen zu Ihrem Widerrufsrecht finden Sie in der Widerrufsbelehrung.', `AGB-Satz „${agb}“`) + // Knopf + const senden = kaeufer.locator('form.wks-form button[type="submit"]') + ok(norm(await senden.innerText()) === 'Zahlungspflichtig bestellen', `Knopf „${norm(await senden.innerText())}“`) + ok(await senden.isEnabled(), 'Knopf frei') + // Reihenfolge: Gewaehrleistung vor Zusammenfassung, AGB-Satz und Knopf am Ende + const reihenfolge = await kaeufer.locator('form.wks-form').evaluate((f) => { + const pos = (sel) => { const e = f.querySelector(sel); return e ? [...f.querySelectorAll('*')].indexOf(e) : -1 } + return [pos('.wks-gewaehr'), pos('.wks-pflicht'), pos('.wks-agb-hinweis'), pos('button[type="submit"]')] + }) + ok(reihenfolge.every((p, i, a) => p > 0 && (i === 0 || p > a[i - 1])), `Reihenfolge Gewährleistung < Pflichtangaben < AGB-Satz < Knopf (${reihenfolge})`) + await shot(kaeufer, '24-shop-kasse') + // AGB-Dialog + await kaeufer.locator('.wks-agb-hinweis').getByRole('button', { name: 'AGB' }).click() + await kaeufer.locator('dialog[open] .wks-dialog-text').waitFor() + ok(enthaelt(await kaeufer.locator('dialog[open] .wks-dialog-text').innerText(), 'Töpferei Brandt'), 'AGB im Dialog (ersetzte Platzhalter)') + await shot(kaeufer, '25-shop-agb-dialog', { telefon: false }) + await kaeufer.locator('dialog[open] .wks-nav').getByRole('button', { name: 'Schließen' }).click() + // bestellen -> Stripe + const vorher = stripeNavigationen.length + await senden.click() + await warteBis(() => stripeNavigationen.length > vorher, 'Weiterleitung zur Stripe-Kasse des Händlers', 15000) + const nav = stripeNavigationen.at(-1) + ok(nav.art === 'checkout' && /\/c\/pay\/cs_/.test(nav.url), `Weiterleitung ${nav.url}`) + const bestellung = srv.bestellungen().at(-1) + ok(bestellung?.status === 'offen', `Bestellung offen (${bestellung?.status})`) + ok(bestellung?.summeCent === 5940, `Bestellsumme ${bestellung?.summeCent} Cent`) + merk.sitzung = bestellung?.stripeSessionId + merk.bestellnummer = bestellung?.bestellnummer + const sitzung = srv.kassenSitzungen().find((s) => s.id === merk.sitzung) + ok(Boolean(sitzung?.success_url?.includes('wk_bestellung=')), `success_url trägt wk_bestellung (${sitzung?.success_url})`) + merk.erfolgUrl = sitzung?.success_url + // Zahlung beim Haendlerkonto, signierter Connect-Webhook + const bezahlt = srv.bezahleKasse(merk.sitzung) + const w = await srv.sendeConnect('checkout.session.completed', bezahlt) + ok(w.status === 200, `Connect-Webhook ${w.status} ${JSON.stringify(w.body)}`) + const nach = srv.bestellungen().find((o) => o.stripeSessionId === merk.sitzung) + ok(nach?.status === 'bezahlt', `Bestellung bezahlt (${nach?.status})`) + const ptasse = srv.produkte().find((p) => p.name === 'Tasse Seladon') + ok(ptasse?.produkt.bestand === 11, `Bestand Tasse 12 -> ${ptasse?.produkt.bestand}`) + const wd = await srv.sendeConnect('checkout.session.completed', bezahlt) + ok(wd.status === 200, 'Wiederholung des Webhooks bleibt 200') + ok(srv.bestellungen().length === 1 && srv.produkte().find((p) => p.name === 'Tasse Seladon').produkt.bestand === 11, 'Wiederholung ändert nichts') + // Rueckkehr + await kaeufer.goto(merk.erfolgUrl) + await kaeufer.locator('.wks-statusbox .wks-ergebnis-text').waitFor() + await warteBis(async () => enthaelt(await kaeufer.locator('.wks-statusbox').innerText(), 'Ihre Zahlung ist eingegangen'), 'Status „Zahlung eingegangen“') + const st = norm(await kaeufer.locator('.wks-statusbox').innerText()) + ok(st.includes(`Vielen Dank für Ihre Bestellung ${merk.bestellnummer}!`), `Dank mit Bestellnummer (${st})`) + ok(st.includes('Bestellnummer') && st.includes('59,40 €'), 'Statusbox: Bestellnummer und Gesamtsumme') + ok(st.includes('m***@example.com'), 'E-Mail verdeckt angezeigt') + ok(!kaeufer.url().includes('wk_t='), `Token aus der Adresse entfernt (${kaeufer.url()})`) + await shot(kaeufer, '26-shop-danke') + }) + + // ------------------------------------------------------------ 4c Demo-Seite (data-api, Cross-Origin) + await lauf('4c_demo_seite_cross_origin', async () => { + const demo = await ctxK.newPage() + await demo.goto(`${srv.kundenUrl}/shop`) + await demo.locator('.wks-raster li').first().waitFor() + ok(new URL(demo.url()).origin === srv.kundenUrl, `Seite kommt von ${new URL(demo.url()).origin}, API von ${srv.portalUrl}`) + ok(await demo.locator('.wks-raster li').count() === 2, 'Demo: zwei Artikel (GET Cross-Origin)') + ok(await demo.locator('.fallback').count() === 0, 'Ausweichtext ersetzt') + ok(await demo.locator('.wks-karte-bild').first().evaluate((img) => img.complete && img.naturalWidth > 0), 'Demo: Artikelbild über data-api geladen') + await shot(demo, '27-demo-seite-raster') + await demo.getByRole('button', { name: 'Tasse Seladon' }).first().click() + await demo.getByRole('button', { name: 'In den Warenkorb' }).click() + await demo.getByRole('button', { name: 'Zum Warenkorb' }).click() + await demo.getByRole('button', { name: 'Zur Kasse' }).click() + await demo.locator('.wks-pflicht .wks-summen').waitFor() // POST /pruefen Cross-Origin (Preflight) + ok(enthaelt(await demo.locator('.wks-summen').innerText(), '23,40 €'), 'Demo: Zusammenfassung vom Server (18,50 € + 4,90 € Versand)') + ok(await demo.locator('.wks-gewaehr img').evaluate((i) => i.complete && i.naturalWidth > 0), 'Demo: Gewährleistungsbild geladen') + const vorher = stripeNavigationen.length + await demo.locator('form.wks-form button[type="submit"]').click() + await warteBis(() => stripeNavigationen.length > vorher, 'Demo: Weiterleitung zur Kasse (POST Cross-Origin)', 15000) + ok(stripeNavigationen.at(-1).art === 'checkout', 'Demo: Bestellung angelegt (Cross-Origin-POST erlaubt)') + const letzte = srv.bestellungen().find((o) => o.status === 'offen') + ok(Boolean(letzte), 'Demo: zweite Bestellung offen (bleibt unbezahlt)') + const s = srv.kassenSitzungen().find((x) => x.id === letzte?.stripeSessionId) + ok(Boolean(s?.success_url?.startsWith(`${srv.kundenUrl}/shop`)), `Demo: Rücksprung auf die Seite des Händlers (${s?.success_url})`) + await demo.close() + }) + + // ------------------------------------------------------------ 5 Mails + await lauf('5_mails_g09_und_k25', async () => { + await srv.warteAufVersand() + const alle = srv.mails() + console.log(` Mails: ${alle.map((m) => `${m.to.map((t) => t.address).join(',')}: ${m.subject}`).join(' | ')}`) + const g09 = alle.find((m) => m.to.some((t) => t.address === 'max@example.com') && enthaelt(m.text || '', 'Mit dieser E-Mail nehmen wir Ihre Bestellung an')) + ok(Boolean(g09), 'G09 an den Käufer mit „Mit dieser E-Mail nehmen wir Ihre Bestellung an“') + if (g09) { + ok(enthaelt(g09.subject, merk.bestellnummer), `G09-Betreff nennt die Bestellnummer (${g09.subject})`) + ok(enthaelt(g09.text, 'Töpferei Brandt'), 'G09 enthält Shopname/AGB des Händlers') + ok(enthaelt(g09.text, 'Widerruf'), 'G09 enthält die Widerrufsbelehrung') + ok(enthaelt(g09.text, '1 × Tasse Seladon'), 'G09 listet die Positionen') + ok(!(g09.bcc?.length), `G09 ohne BCC (${JSON.stringify(g09.bcc)})`) + ok(g09.replyTo?.some?.((r) => r.address === KUNDEN_EMAIL) || g09.replyTo?.address === KUNDEN_EMAIL || /toepferei|Töpferei/i.test(g09.from?.name || ''), `Absendername/Antwort an den Händler (${JSON.stringify(g09.from)} ${JSON.stringify(g09.replyTo)})`) + ok((g09.attachments || []).length >= 0, 'Anhänge geprüft') + } + const k25 = alle.find((m) => m.to.some((t) => t.address === KUNDEN_EMAIL) && /^Neue Bestellung \d\d-/.test(m.subject)) + ok(Boolean(k25), `K25 an den Händler (${alle.filter((m) => m.to.some((t) => t.address === KUNDEN_EMAIL)).map((m) => m.subject).join(' / ')})`) + if (k25) { + ok(!(k25.bcc?.length), `K25 ohne BCC (${JSON.stringify(k25.bcc)})`) + ok(enthaelt(`${k25.subject} ${k25.text}`, merk.bestellnummer), 'K25 nennt die Bestellnummer') + ok(enthaelt(k25.text, 'max@example.com') || enthaelt(k25.text, 'Max Mustermann'), 'K25 nennt den Käufer') + } + // Die Warteschlange ist abgearbeitet + const offen = srv.outbox().filter((e) => e.status && !['sent', 'gesendet', 'done'].includes(e.status)) + ok(offen.length === 0, `Outbox ohne offene Einträge (${offen.map((e) => `${e.art}:${e.status}`).join(',')})`) + }) + + // ------------------------------------------------------------ 6 Haendler: versenden, erstatten + await lauf('6_haendler_bestellung_versendet_erstattung', async () => { + const page = portalPage + await oeffne(page, '#webshop') + await page.reload() + await webshopBereit(page) + await reiter(page, 'bestellungen') + const zeile = page.locator(`[data-bestellung]`).first() + await zeile.waitFor() + ok(enthaelt(await zeile.innerText(), merk.bestellnummer), `Bestellung ${merk.bestellnummer} in der Liste`) + ok(enthaelt(await zeile.innerText(), '59,40 €') && enthaelt(await zeile.innerText(), 'Bezahlt'), 'Zeile: 59,40 €, Status Bezahlt') + await shot(page, '30-bestellungen-liste') + await zeile.click() + await page.locator('[data-aktion="versendet"]').waitFor() + const detail = norm(await page.locator('.sh-bestellungen-inhalt').innerText()) + for (const teil of ['Max Mustermann', 'max@example.com', 'Lieferweg 7', '67655 Kaiserslautern', '1 × Tasse Seladon', '1 × Vase Wabi (Größe: Mittel)', 'Karte']) ok(detail.includes(teil), `Detail zeigt „${teil}“`) + await shot(page, '31-bestellung-detail') + await page.locator('[data-aktion="versendet"]').click() + const f = page.getByRole('form', { name: 'Als versendet markieren' }) + await f.locator('[data-feld="versanddienst"]').fill('DHL') + await f.locator('[data-feld="sendungsnummer"]').fill('00340434161094042557') + await f.locator('[data-aktion="versendet-speichern"]').click() + await warteMeldung(page, 'Als versendet gespeichert') + ok(srv.bestellungen()[0].status === 'versendet', `Status versendet (${srv.bestellungen()[0].status})`) + ok(/DHL, 00340434161094042557/.test(await page.locator('.sh-bestellungen-inhalt').innerText()), 'Detail zeigt Versanddienst und Sendungsnummer') + await srv.warteAufVersand() + const g10 = srv.mails().find((m) => m.to.some((t) => t.address === 'max@example.com') && m.text.includes('00340434161094042557')) + ok(Boolean(g10), 'G10 an den Käufer mit der Sendungsnummer') + if (g10) ok(!(g10.bcc?.length), 'G10 ohne BCC') + await shot(page, '32-bestellung-versendet') + // Erstatten (teilweise) + const betrag = page.locator('#sh-erstatten-betrag') + ok(await betrag.inputValue() === '59,40', `Vorbelegung des Erstattungsbetrags ${await betrag.inputValue()}`) + await betrag.fill('1,00') + await page.locator('[data-aktion="erstatten"]').click() + await page.locator('[data-aktion="erstatten-bestaetigen"]').waitFor() + await shot(page, '33-erstattung-rueckfrage', { telefon: false }) + await page.locator('[data-aktion="erstatten-bestaetigen"]').click() + await warteMeldung(page, 'erstattet') + ok(enthaelt(await echteMeldungen(page), '1,00 € erstattet'), `Meldung „${await echteMeldungen(page)}“`) + const acct = srv.kontoId() + const refundCall = st().calls.find((c) => c.method === 'POST' && c.path === '/v1/refunds') + ok(Boolean(refundCall), 'Stripe-Aufruf POST /v1/refunds') + ok(refundCall?.account === acct, `Erstattung mit Stripe-Account-Kopfzeile ${refundCall?.account} (Konto ${acct})`) + ok(refundCall?.body?.amount === '100' && refundCall?.body?.payment_intent === 'pi_ui_1', `Betrag ${refundCall?.body?.amount}, Zahlung ${refundCall?.body?.payment_intent}`) + const o = srv.bestellungen()[0] + ok(o.status === 'versendet' && /teilerstattet/.test(JSON.stringify(o.daten.verlauf)), 'Teilerstattung im Verlauf, Status bleibt versendet') + await srv.warteAufVersand() + const g13 = srv.mails().find((m) => m.to.some((t) => t.address === 'max@example.com') && /Erstattung|erstattet/i.test(`${m.subject} ${m.text}`) && /1,00/.test(m.text)) + ok(Boolean(g13), 'G13 an den Käufer (1,00 € erstattet)') + await shot(page, '34-erstattung-erledigt') + }) + + // ------------------------------------------------------------ 7 Widerruf + await lauf('7_widerruf', async () => { + const w = await ctxK.newPage() + await w.goto(`${srv.portalUrl}/shop/${key}/widerruf`) + await w.locator('form.wks-form').waitFor() + ok(enthaelt(await w.locator('.wks-titel').innerText(), 'Vertrag widerrufen'), 'Titel „Vertrag widerrufen“') + await shot(w, '40-widerruf-formular') + // Pflichtfelder + await w.getByRole('button', { name: 'Weiter' }).click() + await w.locator('.wks-feldfehler:not([hidden])').first().waitFor() + ok(await w.locator('.wks-feldfehler:not([hidden])').count() >= 2, 'Leeres Formular: Feldfehler') + await w.getByLabel('Ihr Name').fill('Max Mustermann') + await w.getByLabel('E-Mail-Adresse der Bestellung').fill('max@example.com') + await w.getByLabel('Bestellnummer (falls zur Hand)').fill(merk.bestellnummer) + await w.getByLabel('Erklärung').fill('Ich widerrufe die Vase.') + await w.getByRole('button', { name: 'Weiter' }).click() + await w.getByRole('button', { name: 'Widerruf bestätigen' }).waitFor() + ok(enthaelt(await w.locator('.wks-zusammen').innerText(), merk.bestellnummer), 'Prüfseite nennt die Bestellnummer') + await shot(w, '41-widerruf-pruefen') + await w.getByRole('button', { name: 'Widerruf bestätigen' }).click() + await w.locator('.wks-ergebnis-text').waitFor({ timeout: 15000 }) + const quittung = norm(await w.locator('.wks-ergebnis-text').innerText()) + ok(/Ihr Widerruf ist am \d{1,2}\.\d{1,2}\.\d{4}.*\d{1,2}:\d{2}/.test(quittung), `Quittung mit Datum und Uhrzeit („${quittung}“)`) + await shot(w, '42-widerruf-quittung') + ok(srv.widerrufe().length === 1, `Widerruf gespeichert (${srv.widerrufe().length})`) + ok(Boolean(srv.widerrufe()[0]?.orderId), 'Widerruf der Bestellung zugeordnet') + await srv.warteAufVersand() + const g12 = srv.mails().find((m) => m.to.some((t) => t.address === 'max@example.com') && /Widerruf/.test(m.subject)) + ok(Boolean(g12), 'G12 (Eingangsbestätigung) an den Käufer') + if (g12) ok(/\d{2}:\d{2}/.test(g12.text) && /\d{4}/.test(g12.text), 'G12 nennt Datum und Uhrzeit') + const k26 = srv.mails().find((m) => m.to.some((t) => t.address === KUNDEN_EMAIL) && /Widerruf/.test(m.subject)) + ok(Boolean(k26), `K26 an den Händler (${srv.mails().map((m) => m.subject).filter((s) => /Widerruf/.test(s)).join(' / ')})`) + if (k26) ok(!(k26.bcc?.length), 'K26 ohne BCC') + // Haendler sieht den Widerruf + const page = portalPage + await page.reload() + await webshopBereit(page) + await reiter(page, 'bestellungen') + await page.locator('[data-ansicht="widerrufe"]').click() + await page.locator('[data-widerruf]').waitFor() + ok(enthaelt(await page.locator('[data-widerruf]').innerText(), 'Max Mustermann'), 'Widerruf im Reiter Bestellungen') + await shot(page, '43-haendler-widerrufe') + await page.locator('[data-aktion="erledigt"]').click() + await warteMeldung(page, 'als erledigt markiert') + ok(Boolean(srv.widerrufe()[0]?.bearbeitetAt), 'Widerruf als erledigt gespeichert') + await w.close() + }) + + // ------------------------------------------------------------ 8 Shop schliessen + await lauf('8_shop_schliessen_widerruf_bleibt', async () => { + const page = portalPage + await page.reload() + await webshopBereit(page) + await reiter(page, 'uebersicht') + await page.locator('[data-aktion="schliessen"]').click() + await warteMeldung(page, 'Dein Shop ist jetzt geschlossen') + ok(srv.shopDoc()?.geoeffnet === false, 'geoeffnet false im Dokument') + ok(await page.locator('[data-zustand="zu"]').count() === 1, 'Zustand „Dein Shop ist geschlossen“') + await shot(page, '50-uebersicht-geschlossen') + const k = await ctxK.newPage() + await k.goto(`${srv.portalUrl}/shop/${key}`) + await k.locator('.wks-titel').waitFor() + ok(enthaelt(await k.locator('.wks-titel').innerText(), 'Shop geschlossen'), 'Widget: „Shop geschlossen“') + ok(enthaelt(await k.locator('.wks-body').innerText(), 'Der Shop ist gerade geschlossen.'), 'Widget: Text „Der Shop ist gerade geschlossen.“') + ok(await k.locator('.wks-raster').count() === 0 && await k.getByRole('button', { name: /Warenkorb/ }).count() === 0, 'kein Raster, kein Warenkorb') + const widerrufKnopf = k.locator('.wks-fuss').getByRole('button', { name: 'Vertrag widerrufen' }) + ok(await widerrufKnopf.count() === 1, 'Fußzeile: „Vertrag widerrufen“ bleibt') + ok(await k.locator('.wks-fuss').getByRole('button', { name: 'AGB' }).count() === 1, 'Fußzeile: AGB bleibt') + await shot(k, '51-widget-geschlossen') + await widerrufKnopf.click() + await k.locator('form.wks-form').waitFor() + ok(enthaelt(await k.locator('.wks-titel').innerText(), 'Vertrag widerrufen'), 'Widerrufsformular im geschlossenen Shop erreichbar') + // direkte Adresse + await k.goto(`${srv.portalUrl}/shop/${key}/widerruf`) + await k.locator('form.wks-form').waitFor() + ok(true, '/widerruf-Adresse erreichbar') + // Widerruf im geschlossenen Shop wird weiter angenommen + await k.getByLabel('Ihr Name').fill('Erika Muster') + await k.getByLabel('E-Mail-Adresse der Bestellung').fill('erika@example.com') + await k.getByLabel('Erklärung').fill('Bestellt am 05.10.2026: Tasse Seladon.') + await k.getByRole('button', { name: 'Weiter' }).click() + await k.getByRole('button', { name: 'Widerruf bestätigen' }).click() + await k.locator('.wks-ergebnis-text').waitFor({ timeout: 15000 }) + ok(srv.widerrufe().length === 2, `zweiter Widerruf im geschlossenen Shop gespeichert (${srv.widerrufe().length})`) + // POST /pruefen und /bestellung im geschlossenen Shop: 409 + const key2 = srv.siteKey() + const r = await fetch(`${srv.url}/api/public/shop/v1/${key2}/pruefen`, { method: 'POST', headers: { 'content-type': 'application/json', origin: srv.portalUrl }, body: JSON.stringify({ positionen: [{ produktId: srv.produkte()[0].$id, menge: 1 }], lieferart: 'versand', land: 'DE' }) }) + ok(r.status === 409, `POST /pruefen im geschlossenen Shop: ${r.status}`) + await k.close() + }) + + await ctxK.close() +} + +const st = () => srv.st + +// ---------------------------------------------------------------- Lauf + +let abbruch = null +try { + browser = await chromium.launch({ executablePath: CHROME }) + await szenario() + if (srv.siteKey()) await kaeuferSchritte() + const eigene = new Set([srv.url, srv.portalUrl, srv.kundenUrl].map((u) => new URL(u).host)) + const fremd = [...fremdeHosts].filter((h) => !eigene.has(h)) + ergebnisse.push(['nur_eigene_herkunft', fremd.length === 0, fremd.length ? [`Anfragen an fremde Hosts: ${fremd.join(', ')}`] : [], []]) + console.log(fremd.length ? `FAIL nur_eigene_herkunft: ${fremd.join(', ')}` : 'ok nur_eigene_herkunft') +} catch (e) { + abbruch = e + console.log(`ABBRUCH: ${e.stack || e.message}`) +} finally { + if (browser) await browser.close().catch(() => {}) + await srv.stop().catch((e) => console.log(`Server beenden: ${e.message}`)) +} + +const gescheitert = ergebnisse.filter(([, bestanden]) => !bestanden) +console.log(`\n${ergebnisse.length - gescheitert.length}/${ergebnisse.length} Schritte bestanden`) +for (const [name, , fehler] of gescheitert) console.log(` FAIL ${name}\n${fehler.map((f) => ` - ${f}`).join('\n')}`) +process.exit(abbruch || gescheitert.length ? 1 : 0) diff --git a/test/ui/shopTestServer.mjs b/test/ui/shopTestServer.mjs new file mode 100644 index 0000000..eee1be6 --- /dev/null +++ b/test/ui/shopTestServer.mjs @@ -0,0 +1,289 @@ +// UI-Testserver des Webshops (fuer test/ui/shop.ui.mjs): die echten Express-Router (/api/public/shop/v1, /api/shop/stripe-webhook, +// /api/shop, /api/funktionen, /api/abo), die Seiten /shop/:siteKey und /rechtliches/ sowie public/ als statische Dateien, dahinter der +// Nachbau von Appwrite und Stripe (mit Connect) aus test/support/ und eine Mail-Senke. Nichts verlaesst den Rechner (useTestEnv erlaubt nur +// 127.0.0.1). Muster: test/ui/termineTestServer.mjs. +// +// Aufruf in einem Skript (KEINE Server-Module vorher importieren, config.js liest die Umgebung beim ersten Import): +// import { startShopUiServer } from './shopTestServer.mjs' +// const srv = await startShopUiServer() +// ... srv.url, srv.portalUrl, srv.kundenUrl, srv.sink.sent, srv.st (Stripe-Nachbau), srv.aw (Appwrite-Nachbau) ... +// await srv.stop() // erst den Browser schliessen, dann den Server +// +// Uhr: echte Uhr (Server und Browser). Zeitabhaengige Pruefungen lesen die Zeit aus der Antwort. +// +// Mitarbeiter-Schritte rufen die Dienste direkt auf (die Team-Routen brauchen ein Mitarbeiter-JWT): +// srv.entscheide('annehmen' | 'ablehnen', begruendung) entscheideAnfrage +// srv.bezahleEinrichtung() Plattform-Webhook checkout.session.completed fuer die Bestellung der Einrichtung (signiert, /api/abo/webhook) +// srv.schliesseTicket() Einrichtungs-Ticket schliessen (workorders.status 'Closed'); GET /api/funktionen schaltet danach frei +// Haendler-Konto bei Stripe: srv.kontoFreigeben() (charges_enabled, payouts_enabled), Kasse des Kaeufers: srv.bezahleKasse(sessionId) + +// srv.sendeConnect(type, objekt) (signierter Connect-Webhook an /api/shop/stripe-webhook). +// +// Adressen: Der Server lauscht auf srv.url (http://127.0.0.1:PORT, fuer Webhooks und Direktaufrufe). Das Portal heisst fuer Server und Browser +// srv.portalUrl (https://project.webklar.test = config.publicBaseUrl, wie in Produktion https); das Skript leitet diese Adresse im Browser per +// page.route an srv.url um (route.fetch). Die Website des Haendlers heisst srv.kundenUrl (https://www.toepferei-brandt.example = liveDomain des +// Projekts); ihre Seiten liefert das Skript per page.route. So sind Stripe-Rueckspruenge, Bild-Adressen (Widget verlangt https) und +// Cross-Origin-Anfragen (data-api) so wie im Betrieb. +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import express from 'express' +import { startAppwriteMock } from '../support/appwriteMock.js' +import { startFakeStripe } from '../support/fakeStripe.js' +import { mailSink } from '../support/mailSink.js' +import { schemaKeys } from '../support/schema.js' +import { useTestEnv } from '../support/testEnv.js' + +const PUBLIC_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), '../../public') + +export const PORTAL_URL = 'https://project.webklar.test' +export const KUNDEN_URL = 'https://www.toepferei-brandt.example' +export const KUNDE = 'c1' +export const PROJEKT = 'p1' +export const FEATURE = 'f_p1_shop' +export const CONNECT_SECRET = 'whsec_connect_ui_test' +export const KUNDEN_EMAIL = 'hanna@toepferei-brandt.example' +export const KUNDEN_NAME = 'Hanna Brandt' +export const MITARBEITER = { name: 'Kenso', email: 'kenso@webklar.com' } + +/** Anfrage, wie sie das Formular im Portal schickt. */ +export const ANFRAGE = { + warenart: 'kunst', + beschreibung: 'Handgemachte Keramik, Tassen und Vasen aus eigener Werkstatt in Winnweiler.', + artikelzahl: 'bis20', + versand: true, + abholung: true, + unternehmer: true, + keineAusgeschlossenen: true, + nachricht: 'Wir würden gern vor Weihnachten starten.', +} + +export async function startShopUiServer() { + const { SHOP_DATETIMES } = await import('../../server/services/shop/schema.js') + const { TERMINE_DATETIMES } = await import('../../server/services/termine/schema.js') + const aw = await startAppwriteMock({ + schema: { + portalFeatures: schemaKeys('portalFeatures'), + shopConfig: schemaKeys('shopConfig'), + shopProducts: schemaKeys('shopProducts'), + shopOrders: schemaKeys('shopOrders'), + shopWiderrufe: schemaKeys('shopWiderrufe'), + shopRechtstexteArchiv: schemaKeys('shopRechtstexteArchiv'), + termineOutbox: schemaKeys('termineOutbox'), + featureConsents: schemaKeys('featureConsents'), + subscriptions: schemaKeys('subscriptions'), + }, + datetimes: { ...SHOP_DATETIMES, ...TERMINE_DATETIMES }, + defaultLimit: 25, + }) + const st = await startFakeStripe() + useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url }) + + const { config } = await import('../../server/config.js') + const { startTestApp, postWebhook } = await import('../support/testApp.js') + const { setMailTransportForTests } = await import('../../server/services/mailer.js') + const { deleteDocument, updateDocument } = await import('../../server/services/appwriteAdmin.js') + const { default: publicShopRoutes, resetPublicShopForTests, setzeUhrFuerTests: setzeUhrPublic } = await import('../../server/routes/publicShop.js') + const { default: shopRoutes, resetShopRoutesForTests } = await import('../../server/routes/shop.js') + const { default: shopWebhookRoutes } = await import('../../server/routes/shopWebhook.js') + const { default: funktionenRoutes } = await import('../../server/routes/funktionen.js') + const { default: aboRoutes } = await import('../../server/routes/abo.js') + const { shopSeiteRouter, resetShopSeiteForTests } = await import('../../server/routes/shopSeite.js') + const { legalRouter } = await import('../../server/services/legalDocs.js') + const { entscheideAnfrage } = await import('../../server/services/shop/anfrage.js') + const { leereCache } = await import('../../server/services/shop/kontext.js') + const { resetOutboxForTests, warteAufVersand } = await import('../../server/services/termine/outbox.js') + const { resetTermineLimitsForTests } = await import('../../server/services/termine/limits.js') + const { getFeatureDoc } = await import('../../server/services/siteFeatures.js') + + // Reihenfolge wie server/index.js: /embed (CORS, Cache), /rechtliches, Shop-Seiten, public/ + const statisch = express.Router() + statisch.use('/embed/', (_req, res, next) => { + res.set('Access-Control-Allow-Origin', '*') + next() + }) + statisch.use('/rechtliches', legalRouter()) + statisch.use(shopSeiteRouter()) + statisch.use(express.static(PUBLIC_DIR)) + + const app = await startTestApp({ + '/api/public/shop/v1': publicShopRoutes, + '/api/shop/stripe-webhook': shopWebhookRoutes, + '/api/shop': shopRoutes, + '/api/funktionen': funktionenRoutes, + '/api/abo': aboRoutes, + '/': statisch, + }) + const uploadDir = await mkdtemp(path.join(tmpdir(), 'shop-ui-')) + + const vorher = { + mode: config.shop.mode, + pilotEmails: config.shop.pilotEmails, + connectWebhookSecret: config.shop.connectWebhookSecret, + formMinSekunden: config.shop.formMinSekunden, + minAntwortMs: config.shop.minAntwortMs, + uploadDir: config.shop.uploadDir, + publicBaseUrl: config.publicBaseUrl, + smtpFrom: config.smtp.from, + } + + const col = (name) => config.collections[name] + const srv = { + url: app.url, + portalUrl: PORTAL_URL, + kundenUrl: KUNDEN_URL, + KUNDE, PROJEKT, FEATURE, + aw, st, config, + sink: null, + warteAufVersand, + + /** Cookie des Portal-Kunden (c1) fuer page.context().addCookies([...]): { name, value, url }. */ + async portalCookie() { + const { sessionCookie } = await import('../support/testApp.js') + const roh = sessionCookie({ customerId: KUNDE, appwriteUserId: `u_${KUNDE}`, name: KUNDEN_NAME, email: KUNDEN_EMAIL }) + const i = roh.indexOf('=') + return { name: roh.slice(0, i), value: roh.slice(i + 1), url: PORTAL_URL } + }, + + // ---------------------------------------------------------------- Dokumente lesen + featureDoc() { return [...aw.docs('portalFeatures').values()].find((d) => d.featureKey === 'shop') ?? null }, + shopDoc() { return [...aw.docs('shopConfig').values()][0] ?? null }, + siteKey() { return srv.featureDoc()?.siteKey ?? srv.shopDoc()?.siteKey ?? null }, + einstellungen() { return JSON.parse(srv.shopDoc().einstellungen) }, + rechtstexte() { return JSON.parse(srv.shopDoc().rechtstexte) }, + produkte() { return [...aw.docs('shopProducts').values()].map((d) => ({ ...d, produkt: JSON.parse(d.produkt) })) }, + bestellungen() { return [...aw.docs('shopOrders').values()].map((d) => ({ ...d, daten: JSON.parse(d.bestellung) })) }, + widerrufe() { return [...aw.docs('shopWiderrufe').values()] }, + /** Mails der Senke; mit Teiltext des Betreffs filtern. Vorher warteAufVersand() (die Warteschlange sendet im Hintergrund). */ + mails(betreff) { return srv.sink.sent.filter((m) => !betreff || String(m.subject).includes(betreff)) }, + outbox() { return [...aw.docs('termineOutbox').values()] }, + + // ---------------------------------------------------------------- Mitarbeiter-Schritte (Dienste direkt) + async entscheide(entscheidung = 'annehmen', begruendung = '') { + return entscheideAnfrage({ customerId: KUNDE, projectId: PROJEKT, entscheidung, begruendung, person: MITARBEITER }) + }, + + /** Bestellung der Einrichtung (Zustand ordered) bezahlen: Sitzung abschliessen, signierter Plattform-Webhook. Liefert { status } der Antwort. */ + async bezahleEinrichtung() { + const doc = srv.featureDoc() + if (!doc?.stripeSessionId) throw new Error(`keine Bestellung der Einrichtung (Zustand ${doc?.state})`) + const sessionId = doc.stripeSessionId + const invoice = `in_${sessionId}` + st.invoices.set(invoice, { id: invoice, object: 'invoice', hosted_invoice_url: `https://invoice.stripe.test/${invoice}` }) + const sitzung = { ...st.checkoutSessions.get(sessionId), status: 'complete', payment_status: 'paid', invoice } + st.checkoutSessions.set(sessionId, sitzung) + const event = { id: `evt_${Math.random()}`, object: 'event', type: 'checkout.session.completed', data: { object: sitzung } } + return postWebhook(event) + }, + + /** Einrichtungs-Ticket der Funktion schliessen. */ + async schliesseTicket() { + const doc = srv.featureDoc() + if (!doc?.ticketId) throw new Error(`kein Ticket (Zustand ${doc?.state})`) + await updateDocument(col('workorders'), doc.ticketId, { status: 'Closed' }) + }, + + // ---------------------------------------------------------------- Stripe (Haendlerkonto) + /** Das Haendlerkonto des Shops nimmt Zahlungen an (wie nach dem Onboarding bei Stripe). */ + kontoFreigeben(patch = {}) { + const id = srv.shopDoc()?.stripeAccountId + if (!id) throw new Error('kein Stripe-Konto angelegt') + return st.setAccount(id, { charges_enabled: true, payouts_enabled: true, details_submitted: true, requirements: { currently_due: [], past_due: [], disabled_reason: null }, ...patch }) + }, + kontoId() { return srv.shopDoc()?.stripeAccountId ?? null }, + /** Offene Checkout-Sitzungen des Haendlerkontos. */ + kassenSitzungen() { return [...st.accountSessions(srv.kontoId()).values()] }, + /** Sitzung des Kaeufers als bezahlt abschliessen (Stripe-Nachbau); liefert die Sitzung, wie sie der Webhook traegt. */ + bezahleKasse(sessionId, { zahlung = 'pi_ui_1', kunde, versand, betrag } = {}) { + const acct = srv.kontoId() + const sitzung = st.accountSessions(acct).get(sessionId) + if (!sitzung) throw new Error(`keine Sitzung ${sessionId}`) + const order = srv.bestellungen().find((o) => o.stripeSessionId === sessionId) + const summe = betrag ?? order?.summeCent ?? null + st.setPaymentIntent(acct, zahlung, { amount: summe }) + return st.completeSession(acct, sessionId, { + payment_status: 'paid', + amount_total: summe, + customer_details: kunde ?? { + name: 'Max Mustermann', email: 'max@example.com', phone: '+49 170 1234567', + address: { line1: 'Rechnungsweg 1', line2: null, postal_code: '12345', city: 'Musterstadt', country: 'DE' }, + }, + shipping: versand ?? { name: 'Max Mustermann', address: { line1: 'Lieferweg 7', line2: null, postal_code: '67655', city: 'Kaiserslautern', country: 'DE', state: null } }, + payment_intent: zahlung, + payment_method_types: ['card'], + }) + }, + /** Signiertes Connect-Ereignis an POST /api/shop/stripe-webhook -> { status, body }. */ + sendeConnect(type, objekt, { account } = {}) { + const event = { id: `evt_${Math.random().toString(36).slice(2)}`, object: 'event', type, livemode: false, account: account ?? srv.kontoId(), data: { object: objekt } } + return postWebhook(event, { path: '/api/shop/stripe-webhook', secret: CONNECT_SECRET }) + }, + + leereCache() { leereCache() }, + + /** + * Alles auf Anfang: Dokumente loeschen, Zaehler und Zwischenspeicher leeren, neue Senke; Kunde c1 mit Projekt p1 und Wartungs-Abo-freier + * Bestellung (der Webshop verlangt kein Abo). Schalter: Pilotmodus fuer alle Kunden ('*'). + */ + async zuruecksetzen() { + await warteAufVersand() + const sammlungen = [ + 'portalFeatures', 'shopConfig', 'shopProducts', 'shopOrders', 'shopWiderrufe', 'shopRechtstexteArchiv', 'termineOutbox', + 'featureConsents', 'customers', 'websiteProjects', 'workorders', 'subscriptions', + ] + for (const collection of sammlungen) { + for (const id of [...aw.docs(collection).keys()]) await deleteDocument(col(collection) ?? collection, id) + } + st.checkoutSessions.clear() + st.invoices.clear() + st.calls.length = 0 + leereCache() + resetOutboxForTests() + resetTermineLimitsForTests() + resetPublicShopForTests() + resetShopRoutesForTests() + resetShopSeiteForTests() + setzeUhrPublic(() => new Date()) + config.shop.mode = 'pilot' + config.shop.pilotEmails = ['*'] + config.shop.connectWebhookSecret = CONNECT_SECRET + config.shop.formMinSekunden = 0 + config.shop.minAntwortMs = 0 + config.shop.uploadDir = uploadDir + config.smtp.from = 'noreply@webklar.com' + config.publicBaseUrl = PORTAL_URL + srv.sink = mailSink() + setMailTransportForTests(srv.sink) + aw.seed('websiteProjects', { + $id: PROJEKT, customerId: KUNDE, projectName: 'Töpferei Brandt', subdomain: 'toepferei', liveDomain: new URL(KUNDEN_URL).host, + }) + aw.seed('customers', { $id: KUNDE, appwriteUserId: `u_${KUNDE}`, name: KUNDEN_NAME, email: KUNDEN_EMAIL, status: 'active', portalAccessEnabled: true }) + }, + + /** Beendet Server und Nachbauten (zuerst den Browser schliessen) und stellt config wieder her. */ + async stop() { + await warteAufVersand().catch(() => {}) + setMailTransportForTests(null) + resetPublicShopForTests() + resetShopRoutesForTests() + Object.assign(config.shop, { + mode: vorher.mode, pilotEmails: vorher.pilotEmails, connectWebhookSecret: vorher.connectWebhookSecret, formMinSekunden: vorher.formMinSekunden, + minAntwortMs: vorher.minAntwortMs, uploadDir: vorher.uploadDir, + }) + config.publicBaseUrl = vorher.publicBaseUrl + config.smtp.from = vorher.smtpFrom + let frist = null + const geschlossen = app.close() + app.server.closeAllConnections?.() // offene Verbindungen des Browsers (Keep-Alive) halten close() sonst auf + await Promise.race([geschlossen, new Promise((resolve) => { frist = setTimeout(resolve, 3000) })]) + clearTimeout(frist) + await Promise.all([aw.close(), st.close()]) + await rm(uploadDir, { recursive: true, force: true }) + }, + } + + // getFeatureDoc ist nur fuer Skripte gedacht, die den Zustand pruefen wollen + srv.getFeatureDoc = () => getFeatureDoc(KUNDE, PROJEKT, 'shop') + await srv.zuruecksetzen() + return srv +}