Analyse: Einschalten im WOMS nur mit Datum und Weg der Zustimmung in Textform
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
72f07410fe
commit
52968b3017
4 files changed
+183
-21
No files matched your search
@@ -1,6 +1,6 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { fetchAnalyse, setAnalyse } from '../lib/analyseApi'
|
import { fetchAnalyse, setAnalyse } from '../lib/analyseApi'
|
||||||
import { analyseStatus, avvLabel } from '../lib/analyseView'
|
import { analyseStatus, textformLabel, canSubmitTextform, channelLabel, legalLinks, chatTemplate, privacyHint } from '../lib/analyseView'
|
||||||
|
|
||||||
const COLORS = { green: '#10b981', red: '#ef4444', grey: '#a0aec0' }
|
const COLORS = { green: '#10b981', red: '#ef4444', grey: '#a0aec0' }
|
||||||
|
|
||||||
@@ -10,6 +10,9 @@ export default function AnalyseRow({ projectId }) {
|
|||||||
const [loadError, setLoadError] = useState('')
|
const [loadError, setLoadError] = useState('')
|
||||||
const [mode, setMode] = useState(null) // null | 'on' | 'off'
|
const [mode, setMode] = useState(null) // null | 'on' | 'off'
|
||||||
const [confirmed, setConfirmed] = useState(false)
|
const [confirmed, setConfirmed] = useState(false)
|
||||||
|
const [date, setDate] = useState('')
|
||||||
|
const [channel, setChannel] = useState('')
|
||||||
|
const [copied, setCopied] = useState(false)
|
||||||
const [busy, setBusy] = useState(false)
|
const [busy, setBusy] = useState(false)
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
@@ -25,10 +28,12 @@ export default function AnalyseRow({ projectId }) {
|
|||||||
setBusy(true)
|
setBusy(true)
|
||||||
setError('')
|
setError('')
|
||||||
try {
|
try {
|
||||||
await setAnalyse(projectId, { on, avvConfirmedVerbally: on })
|
await setAnalyse(projectId, { on, textformDate: date, textformChannel: channel, audienceConfirmed: on })
|
||||||
setData(await fetchAnalyse(projectId))
|
setData(await fetchAnalyse(projectId))
|
||||||
setMode(null)
|
setMode(null)
|
||||||
setConfirmed(false)
|
setConfirmed(false)
|
||||||
|
setDate('')
|
||||||
|
setChannel('')
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message)
|
setError(err.message)
|
||||||
} finally {
|
} finally {
|
||||||
@@ -36,7 +41,18 @@ export default function AnalyseRow({ projectId }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const cancel = () => { setMode(null); setConfirmed(false); setError('') }
|
const cancel = () => { setMode(null); setConfirmed(false); setDate(''); setChannel(''); setError('') }
|
||||||
|
const today = new Date().toLocaleDateString('sv-SE', { timeZone: 'Europe/Berlin' })
|
||||||
|
const minDate = data?.avvVersion || ''
|
||||||
|
const privacyBlocked = data?.privacy?.ok === false
|
||||||
|
const canSubmit = !privacyBlocked && canSubmitTextform({ confirmed, date, channel, today, minDate })
|
||||||
|
const copyTemplate = async () => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(chatTemplate(data.avvVersion))
|
||||||
|
setCopied(true)
|
||||||
|
setTimeout(() => setCopied(false), 2000)
|
||||||
|
} catch { /* Kopieren nicht möglich: Text bleibt markierbar */ }
|
||||||
|
}
|
||||||
const status = analyseStatus(data)
|
const status = analyseStatus(data)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -59,16 +75,49 @@ export default function AnalyseRow({ projectId }) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{mode === 'on' && data && (
|
{mode === 'on' && data && (() => {
|
||||||
<div style={{ marginTop: 8, display: 'flex', gap: 8, flexWrap: 'wrap', alignItems: 'center' }}>
|
const label = textformLabel(data.avvVersion)
|
||||||
<label style={{ display: 'inline-flex', gap: 6, alignItems: 'center' }}>
|
const links = legalLinks(data.avvVersion)
|
||||||
<input type="checkbox" checked={confirmed} onChange={(e) => setConfirmed(e.target.checked)} />
|
return (
|
||||||
{avvLabel(data.avvVersion)}
|
<div style={{ marginTop: 8, display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||||
</label>
|
<label style={{ display: 'inline-flex', gap: 6, alignItems: 'flex-start' }}>
|
||||||
<button type="button" className="btn btn-sm" disabled={!confirmed || busy} onClick={() => apply(true)}>Einschalten</button>
|
<input type="checkbox" checked={confirmed} onChange={(e) => setConfirmed(e.target.checked)} style={{ marginTop: 3 }} />
|
||||||
<button type="button" className="btn btn-sm" disabled={busy} onClick={cancel}>Abbrechen</button>
|
<span>
|
||||||
</div>
|
{label.before}
|
||||||
)}
|
<a href={links.avv} target="_blank" rel="noopener noreferrer">{label.avv}</a>
|
||||||
|
{label.between}
|
||||||
|
<a href={links.lb} target="_blank" rel="noopener noreferrer">{label.lb}</a>
|
||||||
|
{label.after}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||||
|
<label>
|
||||||
|
Datum der Zustimmung:{' '}
|
||||||
|
<input type="date" required value={date} min={minDate} max={today} onChange={(e) => setDate(e.target.value)} />
|
||||||
|
</label>
|
||||||
|
<span role="radiogroup" aria-label="Weg der Zustimmung" style={{ display: 'inline-flex', gap: 10, alignItems: 'center' }}>
|
||||||
|
<span>Weg:</span>
|
||||||
|
{['chat', 'email'].map((c) => (
|
||||||
|
<label key={c} style={{ display: 'inline-flex', gap: 4, alignItems: 'center' }}>
|
||||||
|
<input type="radio" name={`analyse-weg-${projectId}`} value={c} checked={channel === c} onChange={() => setChannel(c)} />
|
||||||
|
{channelLabel(c)}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{privacyBlocked && <div style={{ color: COLORS.red }}>{privacyHint(data.privacy)}</div>}
|
||||||
|
<details>
|
||||||
|
<summary style={{ cursor: 'pointer' }}>Textvorlage für den Chat</summary>
|
||||||
|
<p style={{ margin: '6px 0', whiteSpace: 'pre-wrap' }}>{chatTemplate(data.avvVersion)}</p>
|
||||||
|
<button type="button" className="btn btn-sm" onClick={copyTemplate}>{copied ? 'Kopiert' : 'Kopieren'}</button>
|
||||||
|
</details>
|
||||||
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
|
<button type="button" className="btn btn-sm" disabled={!canSubmit || busy} onClick={() => apply(true)}>Einschalten</button>
|
||||||
|
<button type="button" className="btn btn-sm" disabled={busy} onClick={cancel}>Abbrechen</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
|
|
||||||
{mode === 'off' && (
|
{mode === 'off' && (
|
||||||
<div style={{ marginTop: 8, display: 'flex', gap: 8, flexWrap: 'wrap', alignItems: 'center' }}>
|
<div style={{ marginTop: 8, display: 'flex', gap: 8, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { account } from './appwrite'
|
import { account } from './appwrite'
|
||||||
|
import { analyseSetBody } from './analyseView'
|
||||||
|
|
||||||
const PROJECT_ADMIN_URL =
|
const PROJECT_ADMIN_URL =
|
||||||
import.meta.env.VITE_PROJECT_ADMIN_URL || 'https://project.webklar.com'
|
import.meta.env.VITE_PROJECT_ADMIN_URL || 'https://project.webklar.com'
|
||||||
@@ -30,6 +31,6 @@ export function fetchAnalyse(projectId) {
|
|||||||
return analyseFetch(base(projectId))
|
return analyseFetch(base(projectId))
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setAnalyse(projectId, { on, avvConfirmedVerbally = false }) {
|
export function setAnalyse(projectId, fields) {
|
||||||
return analyseFetch(base(projectId), { method: 'POST', body: JSON.stringify({ on, avvConfirmedVerbally }) })
|
return analyseFetch(base(projectId), { method: 'POST', body: JSON.stringify(analyseSetBody(fields)) })
|
||||||
}
|
}
|
||||||
+65
-3
@@ -15,13 +15,33 @@ function formatBerlinDate(value) {
|
|||||||
return `${parts.day}.${parts.month}.${parts.year}`
|
return `${parts.day}.${parts.month}.${parts.year}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// "JJJJ-MM-TT" => "TT.MM.JJJJ"
|
||||||
|
function formatIsoDate(value) {
|
||||||
|
const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(String(value || ''))
|
||||||
|
return m ? `${m[3]}.${m[2]}.${m[1]}` : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CHANNELS = ['chat', 'email']
|
||||||
|
|
||||||
|
export function channelLabel(channel) {
|
||||||
|
if (channel === 'chat') return 'Chat im Kundenportal'
|
||||||
|
if (channel === 'email') return 'E-Mail'
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
// Antwort von GET /api/admin/analyse/:projectId => Statustext und erlaubte Aktionen
|
// Antwort von GET /api/admin/analyse/:projectId => Statustext und erlaubte Aktionen
|
||||||
export function analyseStatus(a) {
|
export function analyseStatus(a) {
|
||||||
if (!a) return { text: '', color: 'grey', canEnable: false, canDisable: false }
|
if (!a) return { text: '', color: 'grey', canEnable: false, canDisable: false }
|
||||||
if (!a.enabled) return { text: 'Noch nicht freigeschaltet', color: 'grey', canEnable: false, canDisable: false }
|
if (!a.enabled) return { text: 'Noch nicht freigeschaltet', color: 'grey', canEnable: false, canDisable: false }
|
||||||
if (a.state === 'active') {
|
if (a.state === 'active') {
|
||||||
const at = formatBerlinDate(a.activatedAt)
|
const at = formatBerlinDate(a.activatedAt)
|
||||||
return { text: at ? `An seit ${at}` : 'An', color: 'green', canEnable: false, canDisable: true }
|
let text = at ? `An seit ${at}` : 'An'
|
||||||
|
const consent = formatIsoDate(a.avvConsentDate)
|
||||||
|
if (consent) {
|
||||||
|
const way = channelLabel(a.avvConsentChannel)
|
||||||
|
text += ` · Zustimmung in Textform am ${consent}${way ? ` per ${way}` : ''}`
|
||||||
|
}
|
||||||
|
return { text, color: 'green', canEnable: false, canDisable: true }
|
||||||
}
|
}
|
||||||
if (a.state === 'disabled') return { text: 'Gesperrt', hint: 'Vom Team gesperrt', enableLabel: 'Trotz Sperre einschalten', color: 'red', canEnable: Boolean(a.eligible), canDisable: false }
|
if (a.state === 'disabled') return { text: 'Gesperrt', hint: 'Vom Team gesperrt', enableLabel: 'Trotz Sperre einschalten', color: 'red', canEnable: Boolean(a.eligible), canDisable: false }
|
||||||
if (!a.eligible) {
|
if (!a.eligible) {
|
||||||
@@ -30,5 +50,47 @@ export function analyseStatus(a) {
|
|||||||
return { text: 'Aus', color: 'grey', canEnable: true, canDisable: false }
|
return { text: 'Aus', color: 'grey', canEnable: true, canDisable: false }
|
||||||
}
|
}
|
||||||
|
|
||||||
export const avvLabel = (version) =>
|
// Kästchentext (banner-portal-mailtexte.md § 3); die beiden Links setzt die Komponente
|
||||||
`Der Kunde hat den Auftragsverarbeitungsvertrag (Fassung ${version}) bestätigt`
|
export const textformLabel = (version) => ({
|
||||||
|
before: 'Der Kunde hat dem ',
|
||||||
|
avv: 'AVV Website-Analyse',
|
||||||
|
between: ' und der ',
|
||||||
|
lb: 'Leistungsbeschreibung Website-Analyse',
|
||||||
|
after: ` (Fassung ${version}) in Textform zugestimmt und bestätigt, dass seine Website sich nicht gezielt an Kinder unter 16 Jahren richtet.`,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Alle drei Pflichtangaben; Datum als JJJJ-MM-TT zwischen Fassungsdatum und heute
|
||||||
|
export function canSubmitTextform({ confirmed, date, channel, today, minDate }) {
|
||||||
|
if (confirmed !== true) return false
|
||||||
|
if (!/^\d{4}-\d{2}-\d{2}$/.test(String(date || ''))) return false
|
||||||
|
if (!CHANNELS.includes(channel)) return false
|
||||||
|
if (minDate && date < minDate) return false
|
||||||
|
if (today && date > today) return false
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Body für POST /api/admin/analyse/:projectId
|
||||||
|
export function analyseSetBody({ on, textformDate, textformChannel, audienceConfirmed }) {
|
||||||
|
if (!on) return { on: false }
|
||||||
|
return { on: true, textformDate, textformChannel, audienceConfirmed }
|
||||||
|
}
|
||||||
|
|
||||||
|
const LEGAL_BASE = 'https://project.webklar.com/rechtliches'
|
||||||
|
export const legalLinks = (version) => ({
|
||||||
|
avv: `${LEGAL_BASE}/avv-website-analyse-${version}.html`,
|
||||||
|
lb: `${LEGAL_BASE}/leistungsbeschreibung-website-analyse-${version}.html`,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Textvorlage für den Chat (banner-portal-mailtexte.md § 3)
|
||||||
|
export function chatTemplate(version) {
|
||||||
|
const l = legalLinks(version)
|
||||||
|
return `Damit wir die Website-Analyse für Dich einschalten können, brauchen wir Deine Zustimmung in Textform. Es gelten der Auftragsverarbeitungsvertrag Website-Analyse und die Leistungsbeschreibung Website-Analyse in der Fassung ${version}: ${l.avv} · ${l.lb}. Bitte antworte hier mit „Ich stimme zu“ und bestätige, dass sich Deine Website nicht gezielt an Kinder unter 16 Jahren richtet.`
|
||||||
|
}
|
||||||
|
|
||||||
|
const MISSING_NAMES = { firma: 'Firma', email: 'E-Mail' }
|
||||||
|
// Hinweis, wenn die Kundendaten für die Datenschutzerklärung fehlen (privacy.ok === false)
|
||||||
|
export function privacyHint(privacy) {
|
||||||
|
if (!privacy || privacy.ok !== false) return ''
|
||||||
|
const names = (privacy.missing || []).map((k) => MISSING_NAMES[k] || k).join(' und ')
|
||||||
|
return `Einschalten nicht möglich: Beim Kunden fehlt${names ? `: ${names}` : ' eine Angabe'} (Pflicht für die Datenschutzerklärung).`
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@ import test from 'node:test'
|
|||||||
import assert from 'node:assert/strict'
|
import assert from 'node:assert/strict'
|
||||||
import { loadModule } from './load.mjs'
|
import { loadModule } from './load.mjs'
|
||||||
|
|
||||||
const { analyseStatus, avvLabel } = await loadModule('src/lib/analyseView.js')
|
const { analyseStatus, textformLabel, canSubmitTextform, channelLabel, analyseSetBody, legalLinks, chatTemplate, privacyHint } = await loadModule('src/lib/analyseView.js')
|
||||||
|
|
||||||
test('analyseStatus: Schalter aus => noch nicht freigeschaltet, kein Button', () => {
|
test('analyseStatus: Schalter aus => noch nicht freigeschaltet, kein Button', () => {
|
||||||
const s = analyseStatus({ enabled: false })
|
const s = analyseStatus({ enabled: false })
|
||||||
@@ -48,6 +48,56 @@ test('analyseStatus: robust bei leerer Antwort', () => {
|
|||||||
assert.equal(analyseStatus(null).text, '')
|
assert.equal(analyseStatus(null).text, '')
|
||||||
})
|
})
|
||||||
|
|
||||||
test('avvLabel nennt die Fassung', () => {
|
test('textformLabel nennt die Fassung und die Zielgruppen-Bestaetigung', () => {
|
||||||
assert.equal(avvLabel('2026-10-01'), 'Der Kunde hat den Auftragsverarbeitungsvertrag (Fassung 2026-10-01) bestätigt')
|
const t = textformLabel('2026-10-02')
|
||||||
|
assert.match(t.after, /\(Fassung 2026-10-02\) in Textform zugestimmt und bestätigt, dass seine Website sich nicht gezielt an Kinder unter 16 Jahren richtet\.$/)
|
||||||
|
assert.equal(t.avv, 'AVV Website-Analyse')
|
||||||
|
assert.equal(t.lb, 'Leistungsbeschreibung Website-Analyse')
|
||||||
|
})
|
||||||
|
|
||||||
|
const ok = { confirmed: true, date: '2026-10-02', channel: 'email', today: '2026-10-03', minDate: '2026-10-02' }
|
||||||
|
test('canSubmitTextform: alles gueltig => true (Grenzen eingeschlossen)', () => {
|
||||||
|
assert.equal(canSubmitTextform(ok), true)
|
||||||
|
assert.equal(canSubmitTextform({ ...ok, channel: 'chat', date: '2026-10-03' }), true)
|
||||||
|
})
|
||||||
|
test('canSubmitTextform: fehlende oder ungueltige Angaben => false', () => {
|
||||||
|
assert.equal(canSubmitTextform({ ...ok, confirmed: false }), false)
|
||||||
|
assert.equal(canSubmitTextform({ ...ok, date: '' }), false)
|
||||||
|
assert.equal(canSubmitTextform({ ...ok, channel: '' }), false)
|
||||||
|
assert.equal(canSubmitTextform({ ...ok, channel: 'telefon' }), false)
|
||||||
|
assert.equal(canSubmitTextform({ ...ok, date: '2026-10-01' }), false)
|
||||||
|
assert.equal(canSubmitTextform({ ...ok, date: '2026-10-04' }), false)
|
||||||
|
assert.equal(canSubmitTextform({ ...ok, date: 'morgen' }), false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('channelLabel', () => {
|
||||||
|
assert.equal(channelLabel('chat'), 'Chat im Kundenportal')
|
||||||
|
assert.equal(channelLabel('email'), 'E-Mail')
|
||||||
|
assert.equal(channelLabel('x'), '')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('analyseStatus: aktiv zeigt Zustimmung in Textform', () => {
|
||||||
|
const t = analyseStatus({ enabled: true, state: 'active', eligible: true, activatedAt: '2026-10-03T08:00:00Z', avvConsentDate: '2026-10-02', avvConsentChannel: 'email' }).text
|
||||||
|
assert.ok(t.includes('An seit 03.10.2026'))
|
||||||
|
assert.ok(t.includes('Zustimmung in Textform am 02.10.2026 per E-Mail'))
|
||||||
|
})
|
||||||
|
|
||||||
|
test('analyseSetBody sendet Textform-Felder und kein avvConfirmedVerbally', () => {
|
||||||
|
const b = analyseSetBody({ on: true, textformDate: '2026-10-02', textformChannel: 'chat', audienceConfirmed: true })
|
||||||
|
assert.deepEqual(b, { on: true, textformDate: '2026-10-02', textformChannel: 'chat', audienceConfirmed: true })
|
||||||
|
assert.deepEqual(analyseSetBody({ on: false }), { on: false })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('legalLinks und chatTemplate fuellen echte Links', () => {
|
||||||
|
const l = legalLinks('2026-10-02')
|
||||||
|
assert.equal(l.avv, 'https://project.webklar.com/rechtliches/avv-website-analyse-2026-10-02.html')
|
||||||
|
assert.equal(l.lb, 'https://project.webklar.com/rechtliches/leistungsbeschreibung-website-analyse-2026-10-02.html')
|
||||||
|
const t = chatTemplate('2026-10-02')
|
||||||
|
assert.ok(t.includes(l.avv) && t.includes(l.lb))
|
||||||
|
assert.ok(!t.includes('{Link'))
|
||||||
|
})
|
||||||
|
|
||||||
|
test('privacyHint nennt fehlende Felder', () => {
|
||||||
|
assert.equal(privacyHint({ ok: true, missing: [] }), '')
|
||||||
|
assert.match(privacyHint({ ok: false, missing: ['firma', 'email'] }), /Firma.*E-Mail/)
|
||||||
})
|
})
|
||||||
Reference in new issue
Block a user