feat(marketing): savings-scan form -> compliance backend (real submit + polling)
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-consent (push) Successful in 39s
CI / test-python-voice (push) Successful in 31s
CI / test-bqas (push) Successful in 32s

- POST /api/scan/start: server-proxy zu api-dev.breakpilot.ai/saving-scan/start
  (kein CORS-Bypass, env-konfigurierbar via COMPLIANCE_BACKEND_URL)
- GET /api/scan/status/<checkId>: server-proxy fuer Status-Polling
- savings-scan/page.tsx: echte Submission + 5s-Polling + Progress-Bar + Consent-
  Checkbox + Error-Branch (skipped_tdm, failed)
- Datenschutzhinweis im Disclaimer ergaenzt (§ 44b UrhG TDM-Respekt)

Backend-Endpoint in breakpilot-compliance@6c223c7.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-05-18 23:52:07 +02:00
parent 911697bab4
commit 9a1ad87acd
3 changed files with 170 additions and 18 deletions
@@ -0,0 +1,43 @@
/**
* POST /api/scan/start
* Proxy to compliance backend /api/compliance/agent/saving-scan/start.
*
* Body: { url: string; email: string; consent?: boolean }
*
* Server-side proxy avoids cross-origin POST from breakpilot.ai to
* api-dev.breakpilot.ai — same-origin from the browser, secure egress
* from the Next.js server. Backend handles rate-limit + TDM + lead-DB.
*/
import { NextRequest, NextResponse } from 'next/server'
const BACKEND_URL =
process.env.COMPLIANCE_BACKEND_URL || 'https://api-dev.breakpilot.ai'
export async function POST(request: NextRequest) {
let body: unknown
try {
body = await request.json()
} catch {
return NextResponse.json(
{ error: 'Body muss JSON sein' }, { status: 400 },
)
}
try {
const res = await fetch(
`${BACKEND_URL}/api/compliance/agent/saving-scan/start`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
signal: AbortSignal.timeout(20000),
},
)
const data = await res.json().catch(() => ({}))
return NextResponse.json(data, { status: res.status })
} catch {
return NextResponse.json(
{ error: 'Backend nicht erreichbar' }, { status: 503 },
)
}
}
@@ -0,0 +1,29 @@
/**
* GET /api/scan/status/<checkId>
* Proxy to compliance backend /api/compliance/agent/compliance-check/<id>.
*
* Polled every ~5s by the savings-scan page until status==completed/failed.
*/
import { NextRequest, NextResponse } from 'next/server'
const BACKEND_URL =
process.env.COMPLIANCE_BACKEND_URL || 'https://api-dev.breakpilot.ai'
export async function GET(
_request: NextRequest,
{ params }: { params: Promise<{ checkId: string }> },
) {
const { checkId } = await params
try {
const res = await fetch(
`${BACKEND_URL}/api/compliance/agent/compliance-check/${checkId}`,
{ signal: AbortSignal.timeout(15000) },
)
const data = await res.json().catch(() => ({}))
return NextResponse.json(data, { status: res.status })
} catch {
return NextResponse.json(
{ error: 'Backend nicht erreichbar' }, { status: 503 },
)
}
}
+98 -18
View File
@@ -1,34 +1,76 @@
'use client'
import { useState } from 'react'
import { useEffect, useRef, useState } from 'react'
import Navbar from '@/components/layout/Navbar'
import Footer from '@/components/layout/Footer'
import ChatFAB from '@/components/layout/ChatFAB'
import PageHeader from '@/components/ui/PageHeader'
import GlassCard from '@/components/ui/GlassCard'
import FadeInView from '@/components/ui/FadeInView'
import { Cookie, ShieldCheck, Mail, ArrowRight, CheckCircle2 } from 'lucide-react'
import { Cookie, ShieldCheck, Mail, ArrowRight, CheckCircle2, AlertTriangle } from 'lucide-react'
export default function SavingsScanPage() {
const [url, setUrl] = useState('')
const [email, setEmail] = useState('')
const [consent, setConsent] = useState(true)
const [submitting, setSubmitting] = useState(false)
const [done, setDone] = useState(false)
const [checkId, setCheckId] = useState<string | null>(null)
const [progress, setProgress] = useState<string>('')
const [progressPct, setProgressPct] = useState<number>(0)
const [error, setError] = useState<string | null>(null)
const pollingRef = useRef<boolean>(false)
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
if (!url || !email) return
setError(null)
setSubmitting(true)
try {
// TODO: wire to compliance-check backend
// For now: send to contact form / placeholder
await new Promise(r => setTimeout(r, 800))
const res = await fetch('/api/scan/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url, email, consent }),
})
const data = await res.json()
if (!res.ok) {
setError(data.detail || data.error || 'Scan konnte nicht gestartet werden')
return
}
setCheckId(data.check_id)
setDone(true)
} catch {
setError('Netzwerkfehler — bitte erneut versuchen.')
} finally {
setSubmitting(false)
}
}
useEffect(() => {
if (!checkId || pollingRef.current) return
pollingRef.current = true
let cancelled = false
const poll = async () => {
for (let i = 0; i < 60 && !cancelled; i++) {
await new Promise(r => setTimeout(r, 5000))
try {
const res = await fetch(`/api/scan/status/${checkId}`)
const data = await res.json()
if (data.progress) setProgress(data.progress)
if (typeof data.progress_pct === 'number') setProgressPct(data.progress_pct)
if (['completed', 'failed', 'skipped_tdm'].includes(data.status)) {
if (data.status !== 'completed') {
setError(data.error || 'Scan abgebrochen')
}
return
}
} catch { /* retry */ }
}
}
poll()
return () => { cancelled = true; pollingRef.current = false }
}, [checkId])
return (
<>
<Navbar />
@@ -87,9 +129,24 @@ export default function SavingsScanPage() {
</p>
</div>
<label className="flex items-start gap-2 text-xs text-white/60 cursor-pointer">
<input type="checkbox" checked={consent} onChange={e => setConsent(e.target.checked)}
className="mt-0.5 accent-emerald-500" />
<span>
Ich stimme zu, dass meine E-Mail fuer den Saving-Report + ein
einmaliges Sales-Follow-Up genutzt wird. Widerruf jederzeit per E-Mail.
</span>
</label>
{error && (
<p className="text-sm text-amber-300/80 bg-amber-500/10 border border-amber-400/30 rounded px-3 py-2">
{error}
</p>
)}
<button
type="submit"
disabled={submitting}
disabled={submitting || !consent}
className="inline-flex items-center gap-2 px-6 py-3 rounded-full
bg-emerald-500 hover:bg-emerald-400 transition-colors
text-enterprise-dark font-semibold disabled:opacity-50"
@@ -99,24 +156,47 @@ export default function SavingsScanPage() {
</button>
<p className="text-xs text-white/40 pt-2">
Wir analysieren ausschliesslich oeffentlich abrufbare Daten Ihrer Website.
Keine Anmeldung, keine Daten werden gespeichert. Ergebnis innerhalb von
~5 Minuten per E-Mail.
Wir analysieren ausschliesslich oeffentlich abrufbare Daten Ihrer Website
unter Beachtung maschinenlesbarer Nutzungsvorbehalte (§ 44b UrhG).
Pro Domain max. 1 Scan / 24h. Ergebnis innerhalb von ~3-5 Minuten per E-Mail.
</p>
</form>
</GlassCard>
) : (
<GlassCard>
<div className="text-center py-6">
<CheckCircle2 className="w-12 h-12 text-emerald-400 mx-auto mb-4" />
<h3 className="text-xl font-bold mb-2">Scan gestartet</h3>
<p className="text-white/60 mb-4">
Wir analysieren Ihre Website jetzt. Der Bericht kommt in
den naechsten 5 Minuten per E-Mail an <strong className="text-white/90">{email}</strong>.
</p>
<p className="text-xs text-white/40">
Pruefen Sie auch Ihren Spam-Ordner.
</p>
{error ? (
<AlertTriangle className="w-12 h-12 text-amber-400 mx-auto mb-4" />
) : (
<CheckCircle2 className="w-12 h-12 text-emerald-400 mx-auto mb-4" />
)}
<h3 className="text-xl font-bold mb-2">
{error ? 'Scan-Hinweis' : (progressPct >= 100 ? 'Scan abgeschlossen' : 'Scan laeuft')}
</h3>
{error ? (
<p className="text-amber-300/80 mb-4 text-sm">{error}</p>
) : (
<>
<p className="text-white/60 mb-4">
{progressPct >= 100
? <>Der Bericht ist unterwegs an <strong className="text-white/90">{email}</strong>.</>
: <>Wir analysieren <strong className="text-white/90">{url}</strong>. Bericht kommt in ~3-5 Min an <strong className="text-white/90">{email}</strong>.</>
}
</p>
{progress && progressPct < 100 && (
<div className="max-w-md mx-auto mt-4">
<div className="text-xs text-white/50 mb-2">{progress}</div>
<div className="w-full bg-white/10 rounded-full h-2 overflow-hidden">
<div className="bg-emerald-400 h-full transition-all" style={{ width: `${progressPct}%` }} />
</div>
<div className="text-xs text-white/40 mt-1">{progressPct}%</div>
</div>
)}
<p className="text-xs text-white/40 mt-4">
Pruefen Sie auch Ihren Spam-Ordner.
</p>
</>
)}
</div>
</GlassCard>
)}