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' 'use client'
import { useState } from 'react' import { useEffect, useRef, useState } from 'react'
import Navbar from '@/components/layout/Navbar' import Navbar from '@/components/layout/Navbar'
import Footer from '@/components/layout/Footer' import Footer from '@/components/layout/Footer'
import ChatFAB from '@/components/layout/ChatFAB' import ChatFAB from '@/components/layout/ChatFAB'
import PageHeader from '@/components/ui/PageHeader' import PageHeader from '@/components/ui/PageHeader'
import GlassCard from '@/components/ui/GlassCard' import GlassCard from '@/components/ui/GlassCard'
import FadeInView from '@/components/ui/FadeInView' 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() { export default function SavingsScanPage() {
const [url, setUrl] = useState('') const [url, setUrl] = useState('')
const [email, setEmail] = useState('') const [email, setEmail] = useState('')
const [consent, setConsent] = useState(true)
const [submitting, setSubmitting] = useState(false) const [submitting, setSubmitting] = useState(false)
const [done, setDone] = 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) { async function handleSubmit(e: React.FormEvent) {
e.preventDefault() e.preventDefault()
if (!url || !email) return if (!url || !email) return
setError(null)
setSubmitting(true) setSubmitting(true)
try { try {
// TODO: wire to compliance-check backend const res = await fetch('/api/scan/start', {
// For now: send to contact form / placeholder method: 'POST',
await new Promise(r => setTimeout(r, 800)) 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) setDone(true)
} catch {
setError('Netzwerkfehler — bitte erneut versuchen.')
} finally { } finally {
setSubmitting(false) 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 ( return (
<> <>
<Navbar /> <Navbar />
@@ -87,9 +129,24 @@ export default function SavingsScanPage() {
</p> </p>
</div> </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 <button
type="submit" type="submit"
disabled={submitting} disabled={submitting || !consent}
className="inline-flex items-center gap-2 px-6 py-3 rounded-full className="inline-flex items-center gap-2 px-6 py-3 rounded-full
bg-emerald-500 hover:bg-emerald-400 transition-colors bg-emerald-500 hover:bg-emerald-400 transition-colors
text-enterprise-dark font-semibold disabled:opacity-50" text-enterprise-dark font-semibold disabled:opacity-50"
@@ -99,24 +156,47 @@ export default function SavingsScanPage() {
</button> </button>
<p className="text-xs text-white/40 pt-2"> <p className="text-xs text-white/40 pt-2">
Wir analysieren ausschliesslich oeffentlich abrufbare Daten Ihrer Website. Wir analysieren ausschliesslich oeffentlich abrufbare Daten Ihrer Website
Keine Anmeldung, keine Daten werden gespeichert. Ergebnis innerhalb von unter Beachtung maschinenlesbarer Nutzungsvorbehalte (§ 44b UrhG).
~5 Minuten per E-Mail. Pro Domain max. 1 Scan / 24h. Ergebnis innerhalb von ~3-5 Minuten per E-Mail.
</p> </p>
</form> </form>
</GlassCard> </GlassCard>
) : ( ) : (
<GlassCard> <GlassCard>
<div className="text-center py-6"> <div className="text-center py-6">
<CheckCircle2 className="w-12 h-12 text-emerald-400 mx-auto mb-4" /> {error ? (
<h3 className="text-xl font-bold mb-2">Scan gestartet</h3> <AlertTriangle className="w-12 h-12 text-amber-400 mx-auto mb-4" />
<p className="text-white/60 mb-4"> ) : (
Wir analysieren Ihre Website jetzt. Der Bericht kommt in <CheckCircle2 className="w-12 h-12 text-emerald-400 mx-auto mb-4" />
den naechsten 5 Minuten per E-Mail an <strong className="text-white/90">{email}</strong>. )}
</p> <h3 className="text-xl font-bold mb-2">
<p className="text-xs text-white/40"> {error ? 'Scan-Hinweis' : (progressPct >= 100 ? 'Scan abgeschlossen' : 'Scan laeuft')}
Pruefen Sie auch Ihren Spam-Ordner. </h3>
</p> {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> </div>
</GlassCard> </GlassCard>
)} )}