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
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:
@@ -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 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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">
|
||||||
|
{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" />
|
<CheckCircle2 className="w-12 h-12 text-emerald-400 mx-auto mb-4" />
|
||||||
<h3 className="text-xl font-bold mb-2">Scan gestartet</h3>
|
)}
|
||||||
|
<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">
|
<p className="text-white/60 mb-4">
|
||||||
Wir analysieren Ihre Website jetzt. Der Bericht kommt in
|
{progressPct >= 100
|
||||||
den naechsten 5 Minuten per E-Mail an <strong className="text-white/90">{email}</strong>.
|
? <>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>
|
</p>
|
||||||
<p className="text-xs text-white/40">
|
{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.
|
Pruefen Sie auch Ihren Spam-Ordner.
|
||||||
</p>
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</GlassCard>
|
</GlassCard>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user