diff --git a/src/components/AnalyseRow.jsx b/src/components/AnalyseRow.jsx index 6f429b4..e69ea06 100644 --- a/src/components/AnalyseRow.jsx +++ b/src/components/AnalyseRow.jsx @@ -1,6 +1,6 @@ import { useState, useEffect } from 'react' 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' } @@ -10,6 +10,9 @@ export default function AnalyseRow({ projectId }) { const [loadError, setLoadError] = useState('') const [mode, setMode] = useState(null) // null | 'on' | 'off' const [confirmed, setConfirmed] = useState(false) + const [date, setDate] = useState('') + const [channel, setChannel] = useState('') + const [copied, setCopied] = useState(false) const [busy, setBusy] = useState(false) const [error, setError] = useState('') @@ -25,10 +28,12 @@ export default function AnalyseRow({ projectId }) { setBusy(true) setError('') try { - await setAnalyse(projectId, { on, avvConfirmedVerbally: on }) + await setAnalyse(projectId, { on, textformDate: date, textformChannel: channel, audienceConfirmed: on }) setData(await fetchAnalyse(projectId)) setMode(null) setConfirmed(false) + setDate('') + setChannel('') } catch (err) { setError(err.message) } 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) return ( @@ -59,16 +75,49 @@ export default function AnalyseRow({ projectId }) { )} - {mode === 'on' && data && ( -
- - - -
- )} + {mode === 'on' && data && (() => { + const label = textformLabel(data.avvVersion) + const links = legalLinks(data.avvVersion) + return ( +
+ +
+ + + Weg: + {['chat', 'email'].map((c) => ( + + ))} + +
+ {privacyBlocked &&
{privacyHint(data.privacy)}
} +
+ Textvorlage für den Chat +

{chatTemplate(data.avvVersion)}

+ +
+
+ + +
+
+ ) + })()} {mode === 'off' && (
diff --git a/src/lib/analyseApi.js b/src/lib/analyseApi.js index 487b554..26050d9 100644 --- a/src/lib/analyseApi.js +++ b/src/lib/analyseApi.js @@ -1,4 +1,5 @@ import { account } from './appwrite' +import { analyseSetBody } from './analyseView' const PROJECT_ADMIN_URL = import.meta.env.VITE_PROJECT_ADMIN_URL || 'https://project.webklar.com' @@ -30,6 +31,6 @@ export function fetchAnalyse(projectId) { return analyseFetch(base(projectId)) } -export function setAnalyse(projectId, { on, avvConfirmedVerbally = false }) { - return analyseFetch(base(projectId), { method: 'POST', body: JSON.stringify({ on, avvConfirmedVerbally }) }) +export function setAnalyse(projectId, fields) { + return analyseFetch(base(projectId), { method: 'POST', body: JSON.stringify(analyseSetBody(fields)) }) } diff --git a/src/lib/analyseView.js b/src/lib/analyseView.js index aac8916..f967b5b 100644 --- a/src/lib/analyseView.js +++ b/src/lib/analyseView.js @@ -15,13 +15,33 @@ function formatBerlinDate(value) { 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 export function analyseStatus(a) { 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.state === 'active') { 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.eligible) { @@ -30,5 +50,47 @@ export function analyseStatus(a) { return { text: 'Aus', color: 'grey', canEnable: true, canDisable: false } } -export const avvLabel = (version) => - `Der Kunde hat den Auftragsverarbeitungsvertrag (Fassung ${version}) bestätigt` +// Kästchentext (banner-portal-mailtexte.md § 3); die beiden Links setzt die Komponente +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).` +} diff --git a/tests/unit/analyseView.test.mjs b/tests/unit/analyseView.test.mjs index 60f6e01..194dffd 100644 --- a/tests/unit/analyseView.test.mjs +++ b/tests/unit/analyseView.test.mjs @@ -2,7 +2,7 @@ import test from 'node:test' import assert from 'node:assert/strict' 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', () => { const s = analyseStatus({ enabled: false }) @@ -48,6 +48,56 @@ test('analyseStatus: robust bei leerer Antwort', () => { assert.equal(analyseStatus(null).text, '') }) -test('avvLabel nennt die Fassung', () => { - assert.equal(avvLabel('2026-10-01'), 'Der Kunde hat den Auftragsverarbeitungsvertrag (Fassung 2026-10-01) bestätigt') +test('textformLabel nennt die Fassung und die Zielgruppen-Bestaetigung', () => { + 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/) })