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 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-05 09:45:31 +00:00
1 parent 0a2eab85d5
commit 00fa229235
4 files changed
+112 -23

No files matched your search

+11 -11
View File
@@ -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 (
<div className="area-row funktionen-panel">
@@ -140,7 +140,7 @@ function TextformPanel({ termine, value, onChange, projectId, today, disabled })
<span>Weg:</span>
{CHANNELS.map((c) => (
<label key={c} className="area-inline">
<input type="radio" name={`termine-weg-${projectId}`} value={c} checked={value.channel === c} disabled={disabled} onChange={() => onChange({ ...value, channel: c })} />
<input type="radio" name={`textform-weg-${funktion}-${projectId}`} value={c} checked={value.channel === c} disabled={disabled} onChange={() => onChange({ ...value, channel: c })} />
{channelLabel(c)}
</label>
))}
@@ -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 (
<Fragment key={`${p.projectId}:${f.key}`}>
<div className="area-row funktionen-row">
@@ -359,7 +359,7 @@ export default function CustomerFunktionen({ customerId }) {
</span>
</div>
{braucheTextform && (
<TextformPanel termine={termine} value={textform} onChange={setTextform} projectId={p.projectId} today={heute} disabled={busy} />
<TextformPanel funktion={f.key} feld={felder[f.key]} value={textform} onChange={setTextform} projectId={p.projectId} today={heute} disabled={busy} />
)}
{anleitungOffenHier && (
<AnleitungPanel customerId={customerId} projectId={p.projectId} feature={feature} reloadKey={panelReload[p.projectId] || 0} />
+32 -11
View File
@@ -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 })
}
+1 -1
View File
@@ -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 }) })
+68
View File
@@ -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(