From 00fa229235facdb26a834b855980ff6accfa82cf Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 09:45:31 +0000 Subject: [PATCH] Shop Review B I-8: Webshop ohne Zahlung nur mit Zustimmung in Textform aktivieren Das Textform-Formular (Haken, Datum, Weg, Chat-Vorlage) gilt wie beim Terminplaner auch fuer den Webshop, wenn das Portal im Feld shop textformNoetig meldet. Kaestchentext und Chat-Vorlage nennen die Unterlagen des Webshops, die Fassung und die Erklaerungen, die das Portal im Nachweis speichert. Co-Authored-By: Claude Opus 5.5 --- .../customer/CustomerFunktionen.jsx | 22 +++--- src/lib/funktionenView.js | 43 +++++++++--- src/lib/teamApi.js | 2 +- tests/unit/funktionenView.test.mjs | 68 +++++++++++++++++++ 4 files changed, 112 insertions(+), 23 deletions(-) diff --git a/src/components/customer/CustomerFunktionen.jsx b/src/components/customer/CustomerFunktionen.jsx index 73b4a04..d6cd1d4 100644 --- a/src/components/customer/CustomerFunktionen.jsx +++ b/src/components/customer/CustomerFunktionen.jsx @@ -114,10 +114,10 @@ function AnleitungPanel({ customerId, projectId, feature, reloadKey }) { ) } -// Zustimmung des Kunden in Textform (Terminplaner im Modus on): Haken, Datum, Weg und die Textvorlage fuer den Chat. Die Pflichtangaben +// Zustimmung des Kunden in Textform (Terminplaner und Webshop im Modus on): Haken, Datum, Weg und die Textvorlage fuer den Chat. Die Pflichtangaben // prueft textformBereit (funktionenView); das Portal prueft sie noch einmal. Links nur als geprueftes https (textformAnsicht). -function TextformPanel({ termine, value, onChange, projectId, today, disabled }) { - const ansicht = textformAnsicht(termine) +function TextformPanel({ funktion, feld, value, onChange, projectId, today, disabled }) { + const ansicht = textformAnsicht(feld, funktion) const { label, links, vorlage } = ansicht return (
@@ -140,7 +140,7 @@ function TextformPanel({ termine, value, onChange, projectId, today, disabled }) Weg: {CHANNELS.map((c) => ( ))} @@ -232,7 +232,7 @@ const LEERE_TEXTFORM = { confirmed: false, date: '', channel: '' } export default function CustomerFunktionen({ customerId }) { const [projects, setProjects] = useState(null) - const [termine, setTermine] = useState(null) // Portal-Feld termine: { textformNoetig, avvVersion, links } + const [felder, setFelder] = useState({ termine: null, shop: null }) // Portal-Felder termine und shop: { textformNoetig, avvVersion, links } const [textform, setTextform] = useState(LEERE_TEXTFORM) const [error, setError] = useState(null) const [confirm, setConfirm] = useState(null) // { projectId, key, action } @@ -247,7 +247,7 @@ export default function CustomerFunktionen({ customerId }) { const load = useCallback(() => { setError(null) getFunktionen(customerId) - .then(({ projects: list, termine: feld }) => { setProjects(list || []); setTermine(feld || null) }) + .then(({ projects: list, termine, shop: shopFeld }) => { setProjects(list || []); setFelder({ termine: termine || null, shop: shopFeld || null }) }) .catch((err) => setError(err.message || 'Funktionen konnten nicht geladen werden.')) }, [customerId]) const loadShop = useCallback(() => { @@ -257,14 +257,14 @@ export default function CustomerFunktionen({ customerId }) { .catch((err) => setShopFehler(err.message || 'Webshop-Daten konnten nicht geladen werden.')) }, [customerId]) useEffect(() => { load(); loadShop() }, [load, loadShop]) - const nachEntscheidung = () => Promise.all([getFunktionen(customerId).then(({ projects: list, termine: feld }) => { setProjects(list || []); setTermine(feld || null) }), loadShop()]) + const nachEntscheidung = () => Promise.all([getFunktionen(customerId).then(({ projects: list, termine, shop: shopFeld }) => { setProjects(list || []); setFelder({ termine: termine || null, shop: shopFeld || null }) }), loadShop()]) const save = async () => { const { projectId, key, action } = confirm setBusy(true) setStatus(null) try { - const mitTextform = textformNoetig(key, action, termine) + const mitTextform = textformNoetig(key, action, felder[key]) const antwort = await setFeature(customerId, key, { projectId, action, note: note.trim(), ...(mitTextform ? { textformDate: textform.date, textformChannel: textform.channel } : {}), @@ -306,8 +306,8 @@ export default function CustomerFunktionen({ customerId }) { const isConfirming = confirm?.projectId === p.projectId && confirm?.key === f.key const hatAnleitung = f.key === 'termine' && feature.state !== 'off' const anleitungOffenHier = hatAnleitung && panelOffen(p.projectId) - const braucheTextform = isConfirming && textformNoetig(f.key, confirm.action, termine) - const textformFehlt = braucheTextform && !textformBereit({ ...textform, today: heute }, termine) + const braucheTextform = isConfirming && textformNoetig(f.key, confirm.action, felder[f.key]) + const textformFehlt = braucheTextform && !textformBereit({ ...textform, today: heute }, felder[f.key]) return (
@@ -359,7 +359,7 @@ export default function CustomerFunktionen({ customerId }) {
{braucheTextform && ( - + )} {anleitungOffenHier && ( diff --git a/src/lib/funktionenView.js b/src/lib/funktionenView.js index dbfa696..3d00370 100644 --- a/src/lib/funktionenView.js +++ b/src/lib/funktionenView.js @@ -174,10 +174,16 @@ export function mergeFunktion(projects, projectId, key, aktualisiert) { return projects.map((p) => (p.projectId === projectId ? { ...p, features: { ...p.features, [key]: view } } : p)) } -// --- Aktivieren (ohne Zahlung) mit Zustimmung in Textform (Modus on des Terminplaners; Portal: GET /funktionen/:customerId => termine) --- +// --- Aktivieren (ohne Zahlung) mit Zustimmung in Textform (Modus on des Terminplaners und des Webshops; Portal: GET /funktionen/:customerId => termine, shop) --- -/** true, wenn diese Aktion die Zustimmung in Textform braucht: nur "Aktivieren (ohne Zahlung)" des Terminplaners und nur, wenn das Portal es verlangt (termine.textformNoetig === true). */ -export const textformNoetig = (key, action, termine) => key === 'termine' && action === 'activate' && termine?.textformNoetig === true +/** Funktionen, deren Aktivierung durch das Team die Zustimmung des Kunden in Textform brauchen kann (Portal: TEXTFORM_KEYS). */ +export const TEXTFORM_FUNKTIONEN = ['termine', 'shop'] + +/** + * true, wenn diese Aktion die Zustimmung in Textform braucht: nur "Aktivieren (ohne Zahlung)" des Terminplaners oder des Webshops und nur, wenn das + * Portal es verlangt (feld = Portal-Feld dieser Funktion, termine bzw. shop, feld.textformNoetig === true). + */ +export const textformNoetig = (key, action, feld) => TEXTFORM_FUNKTIONEN.includes(key) && action === 'activate' && feld?.textformNoetig === true /** Kaestchentext (wie im Portal-Nachweis, ohne Datum und Weg); die beiden Links setzt die Komponente. */ export const termineTextformLabel = (version) => ({ @@ -188,10 +194,23 @@ export const termineTextformLabel = (version) => ({ after: ` (Fassung ${version}) in Textform zugestimmt und bestätigt, dass sein Angebot nicht im Gesundheitsbereich liegt.`, }) +/** Kaestchentext des Webshops; die Erklaerungen entsprechen den Bestelltexten "unterlagen", "verantwortung" und "stripe", die das Portal im Nachweis speichert. */ +export const shopTextformLabel = (version) => ({ + before: 'Der Kunde hat der ', + lb: 'Leistungsbeschreibung Webshop', + between: ' und dem ', + avv: 'Auftragsverarbeitungsvertrag Webshop', + after: ` (Fassung ${version}) in Textform zugestimmt und bestätigt, dass er als Unternehmer in eigenem Namen verkauft, für seine Produkte, Preise, Produktangaben und Rechtstexte selbst verantwortlich ist, keine der in der Leistungsbeschreibung ausgeschlossenen Waren verkauft und die Zahlungen seiner Käufer über sein eigenes Stripe-Konto laufen.`, +}) + /** Textvorlage fuer den Chat (Kopierknopf); links = die versionierten Adressen aus dem Portal (termine.links). */ export const termineChatTemplate = (version, links) => `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 ${version}: ${links.lb} · ${links.avv}. Bitte antworte hier mit „Ich stimme zu“ und bestätige, dass Dein Angebot nicht im Gesundheitsbereich liegt.` +/** Textvorlage fuer den Chat beim Webshop; links = shop.links. Die Bestaetigung deckt dieselben Erklaerungen wie shopTextformLabel. */ +export const shopChatTemplate = (version, links) => + `Damit wir den Webshop für Dich einschalten können, brauchen wir Deine Zustimmung in Textform. Es gelten die Leistungsbeschreibung Webshop und der Auftragsverarbeitungsvertrag Webshop in der Fassung ${version}: ${links.lb} · ${links.avv}. Bitte antworte hier mit „Ich stimme zu“ und bestätige, dass Du als Unternehmer in eigenem Namen verkaufst, für Deine Produkte, Preise, Produktangaben und Rechtstexte selbst verantwortlich bist, keine der in der Leistungsbeschreibung ausgeschlossenen Waren verkaufst und Deine Zahlungen über Dein eigenes Stripe-Konto laufen.` + /** Nur geprüfte https-Adressen werden Links; alles andere ''. */ const httpsUrl = (wert) => { if (typeof wert !== 'string') return '' @@ -203,23 +222,25 @@ const httpsUrl = (wert) => { } /** - * Alles, was der Bestaetigungsdialog zeigt, aus dem Portal-Feld termine: { version, links { lb, avv } (nur https, sonst ''), label, vorlage }. + * Alles, was der Bestaetigungsdialog zeigt, aus dem Portal-Feld der Funktion (feld = termine bzw. shop, key = 'termine' | 'shop'): + * { version, links { lb, avv } (nur https, sonst ''), label, vorlage }. * vorlage nur, wenn Fassung und beide Adressen da sind (eine Vorlage mit leeren Adressen geht nie an den Kunden), sonst ''. */ -export function textformAnsicht(termine) { - const version = typeof termine?.avvVersion === 'string' ? termine.avvVersion : '' - const links = { lb: httpsUrl(termine?.links?.lb), avv: httpsUrl(termine?.links?.avv) } +export function textformAnsicht(feld, key = 'termine') { + const shop = key === 'shop' + const version = typeof feld?.avvVersion === 'string' ? feld.avvVersion : '' + const links = { lb: httpsUrl(feld?.links?.lb), avv: httpsUrl(feld?.links?.avv) } return { version, links, - label: termineTextformLabel(version), - vorlage: version && links.lb && links.avv ? termineChatTemplate(version, links) : '', + label: (shop ? shopTextformLabel : termineTextformLabel)(version), + vorlage: version && links.lb && links.avv ? (shop ? shopChatTemplate : termineChatTemplate)(version, links) : '', } } /** Alle drei Pflichtangaben (Haken, Datum zwischen Fassung und heute, Weg); ohne Fassung im Portal-Feld nie bereit. */ -export function textformBereit({ confirmed, date, channel, today }, termine) { - const minDate = typeof termine?.avvVersion === 'string' ? termine.avvVersion : '' +export function textformBereit({ confirmed, date, channel, today }, feld) { + const minDate = typeof feld?.avvVersion === 'string' ? feld.avvVersion : '' if (!minDate) return false return canSubmitTextform({ confirmed, date, channel, today, minDate }) } diff --git a/src/lib/teamApi.js b/src/lib/teamApi.js index 5be03a7..88f8e33 100644 --- a/src/lib/teamApi.js +++ b/src/lib/teamApi.js @@ -95,7 +95,7 @@ export const markStorageDone = (id) => request(`/storage-tasks/${enc(id)}/done`, export const getFunktionen = (customerId) => request(`/funktionen/${enc(customerId)}`) /** * Aktivieren (ohne Zahlung) oder Abschalten einer Funktion (key: news | termine | shop); Antwort { projects: [das eine Projekt] }. - * Terminplaner im Modus on: beim Aktivieren zusaetzlich textformDate (JJJJ-MM-TT) und textformChannel (chat | email). + * Terminplaner und Webshop im Modus on: beim Aktivieren zusaetzlich textformDate (JJJJ-MM-TT) und textformChannel (chat | email). */ export const setFeature = (customerId, key, { projectId, action, note, textformDate, textformChannel }) => request(`/funktionen/${enc(customerId)}/${enc(key)}`, { method: 'POST', body: featureBody({ projectId, action, note, textformDate, textformChannel }) }) diff --git a/tests/unit/funktionenView.test.mjs b/tests/unit/funktionenView.test.mjs index fb1d9dc..e74b836 100644 --- a/tests/unit/funktionenView.test.mjs +++ b/tests/unit/funktionenView.test.mjs @@ -20,6 +20,9 @@ const { textformNoetig, termineTextformLabel, termineChatTemplate, + shopTextformLabel, + shopChatTemplate, + TEXTFORM_FUNKTIONEN, textformAnsicht, textformBereit, featureBody, @@ -393,6 +396,71 @@ test('textformNoetig: nur "Aktivieren (ohne Zahlung)" des Terminplaners und nur, assert.equal(textformNoetig(undefined, 'activate', termine()), false) }) +// Feld "shop" der Portal-Antwort GET /funktionen/:customerId (Fassung = config.shop.fassung) +const shopFeld = (extra = {}) => ({ + textformNoetig: true, + avvVersion: '2026-10-05', + links: { + lb: 'https://project.webklar.com/rechtliches/leistungsbeschreibung-webshop-2026-10-05.html', + avv: 'https://project.webklar.com/rechtliches/avv-webshop-2026-10-05.html', + }, + ...extra, +}) + +test('textformNoetig: auch das Aktivieren (ohne Zahlung) des Webshops, mit dem Feld shop; die Funktion kommt aus dem Schlüssel', () => { + assert.deepEqual(TEXTFORM_FUNKTIONEN, ['termine', 'shop']) + assert.equal(textformNoetig('shop', 'activate', shopFeld()), true) + assert.equal(textformNoetig('shop', 'activate', shopFeld({ textformNoetig: false })), false) + assert.equal(textformNoetig('shop', 'activate', undefined), false, 'altes Portal ohne Feld shop: wie bisher') + assert.equal(textformNoetig('shop', 'activate', null), false) + assert.equal(textformNoetig('shop', 'activate', shopFeld({ textformNoetig: 'true' })), false, 'nur echtes true') + for (const action of ['disable', 'abnahme', 'ticket']) assert.equal(textformNoetig('shop', action, shopFeld()), false, action) + assert.equal(textformNoetig('news', 'activate', shopFeld()), false) +}) + +test('shopTextformLabel und shopChatTemplate: wörtlich, mit Fassung, den Erklärungen des Nachweises und den versionierten Adressen', () => { + const l = shopTextformLabel('2026-10-05') + assert.equal(l.lb, 'Leistungsbeschreibung Webshop') + assert.equal(l.avv, 'Auftragsverarbeitungsvertrag Webshop') + assert.equal( + l.before + l.lb + l.between + l.avv + l.after, + 'Der Kunde hat der Leistungsbeschreibung Webshop und dem Auftragsverarbeitungsvertrag Webshop (Fassung 2026-10-05) ' + + 'in Textform zugestimmt und bestätigt, dass er als Unternehmer in eigenem Namen verkauft, für seine Produkte, Preise, Produktangaben und Rechtstexte selbst verantwortlich ist, ' + + 'keine der in der Leistungsbeschreibung ausgeschlossenen Waren verkauft und die Zahlungen seiner Käufer über sein eigenes Stripe-Konto laufen.', + ) + const { lb, avv } = shopFeld().links + const text = shopChatTemplate('2026-10-05', { lb, avv }) + assert.ok(text.startsWith('Damit wir den Webshop für Dich einschalten können, brauchen wir Deine Zustimmung in Textform. ')) + assert.ok(text.includes(`in der Fassung 2026-10-05: ${lb} · ${avv}. `)) + assert.match(text, /leistungsbeschreibung-webshop-2026-10-05\.html/) + assert.match(text, /avv-webshop-2026-10-05\.html/) + assert.match(text, /Ich stimme zu/) + assert.match(text, /eigenes Stripe-Konto/) + assert.ok(!text.includes('undefined') && !text.includes('{') && !text.includes('Terminplaner')) +}) + +test('textformAnsicht(feld, "shop"): Label, Vorlage und Links des Webshops; ohne Schlüssel bleibt es der Terminplaner', () => { + const a = textformAnsicht(shopFeld(), 'shop') + assert.equal(a.version, '2026-10-05') + assert.deepEqual(a.links, shopFeld().links) + assert.deepEqual(a.label, shopTextformLabel('2026-10-05')) + assert.equal(a.vorlage, shopChatTemplate('2026-10-05', shopFeld().links)) + assert.deepEqual(textformAnsicht(termine()).label, termineTextformLabel('2026-10-05')) + // Adressen ohne https werden nie Link, ohne beide Adressen keine Vorlage + const schlecht = textformAnsicht(shopFeld({ links: { lb: 'javascript:alert(1)', avv: 'http://x.example/a.html' } }), 'shop') + assert.deepEqual(schlecht.links, { lb: '', avv: '' }) + assert.equal(schlecht.vorlage, '') + assert.equal(textformAnsicht(undefined, 'shop').version, '') +}) + +test('textformBereit mit dem Feld shop: Datum zwischen Fassung und heute, ohne Fassung nie bereit', () => { + const ok = { confirmed: true, date: '2026-10-06', channel: 'email', today: '2026-10-07' } + assert.equal(textformBereit(ok, shopFeld()), true) + assert.equal(textformBereit({ ...ok, date: '2026-10-04' }, shopFeld()), false, 'vor der Fassung') + assert.equal(textformBereit(ok, shopFeld({ avvVersion: '' })), false) + assert.equal(textformBereit(ok, undefined), false) +}) + test('termineChatTemplate: wörtlich, mit Fassung und den beiden versionierten Adressen', () => { const { lb, avv } = termine().links assert.equal(