'use client' import { useCallback, useEffect, useState } from 'react' import { useRouter } from 'next/navigation' import { Check, Loader2, Circle, AlertTriangle } from 'lucide-react' import { Button } from '@/components/ui/button' import { Card, CardContent } from '@/components/ui/card' import { cn } from '@/lib/utils' import ReviewCard from './ReviewCard' type StepId = 'tic' | 'select' | 'narrative' | 'finalize' type StepStatus = 'pending' | 'in_progress' | 'success' | 'fallback' | 'error' interface StepConfig { id: StepId label: string fallbackLabel?: string } const STEPS: StepConfig[] = [ { id: 'tic', label: 'Hämtar uppgifter', fallbackLabel: 'Inga företagsuppgifter, fortsätter ändå' }, { id: 'select', label: 'Identifierar din verksamhet', fallbackLabel: 'Använder standardval' }, { id: 'narrative', label: 'Sammanfattar', fallbackLabel: 'Standardsammanfattning' }, { id: 'finalize', label: 'Klar' }, ] interface InitialFields { entity_type_label: string sni_codes: { code: string; name: string }[] purpose: string | null city: string | null fiscal_period: string | null vat_period: string | null f_skatt: string | null employees: string | null } interface ProfilePayload { company_id: string horizontal_atoms: string[] vertical_atoms: string[] modifier_atoms: string[] is_multi_vertical: boolean profile_summary: string verification_questions: string[] uncertainty_notes: string[] composer_model: string composed_at: string } interface Props { companyId: string companyName: string firstName: string | null initialFields: InitialFields atomTitles: Record alreadyVerified: boolean existingSummary: string | null } export default function AgentOnboarding({ companyId, companyName, firstName, initialFields, atomTitles, alreadyVerified, existingSummary, }: Props) { const router = useRouter() const [phase, setPhase] = useState<'building' | 'review' | 'done'>( alreadyVerified ? 'review' : 'building', ) const [statuses, setStatuses] = useState>({ tic: 'pending', select: 'pending', narrative: 'pending', finalize: 'pending', }) // Hydrate from props during the initial render so we don't flash an empty // ReviewCard for already-verified profiles. const [profile, setProfile] = useState(() => { if (alreadyVerified && existingSummary) { return { company_id: companyId, horizontal_atoms: [], vertical_atoms: [], modifier_atoms: [], is_multi_vertical: false, profile_summary: existingSummary, verification_questions: [], uncertainty_notes: [], composer_model: '', composed_at: '', } } return null }) const [errorMessage, setErrorMessage] = useState(null) const setStatus = useCallback((id: StepId, status: StepStatus) => { setStatuses((prev) => ({ ...prev, [id]: status })) }, []) // No started-ref guard: React 19 Strict Mode runs this effect twice in dev. // The first invocation's cleanup aborts its fetch; the second invocation // makes the request that actually completes. The stream endpoint is // idempotent (it upserts agent_profiles on company_id), so a transient // duplicate call during Strict Mode rerun is safe. useEffect(() => { if (alreadyVerified) return const controller = new AbortController() void runStream(companyId, controller.signal, { setStatus, onProfile: (p) => setProfile(p), onError: (msg) => setErrorMessage(msg), onComplete: () => setPhase('review'), }) return () => { controller.abort() } }, [companyId, alreadyVerified, setStatus]) if (phase === 'review' || phase === 'done') { return ( router.push('/chat/intake')} /> ) } return (

{firstName ? `${firstName}, ` : ''}ett ögonblick

Vi bygger din bokföringsassistent

Skräddarsyr för {companyName}. Tar oftast under en halv minut.

    {STEPS.map((step) => ( ))}
{errorMessage && (

Något gick fel

{errorMessage}

)}
) } function StepRow({ step, status }: { step: StepConfig; status: StepStatus }) { return (
  • {status === 'success' && } {status === 'fallback' && } {status === 'in_progress' && } {status === 'pending' && } {status === 'error' && } {status === 'fallback' && step.fallbackLabel ? step.fallbackLabel : step.label}
  • ) } async function runStream( companyId: string, signal: AbortSignal, cbs: { setStatus: (id: StepId, status: StepStatus) => void onProfile: (p: ProfilePayload) => void onError: (msg: string) => void onComplete: () => void }, ): Promise { let response: Response try { response = await fetch('/api/agent/onboarding/stream', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ company_id: companyId }), signal, }) } catch (err) { if (signal.aborted) return cbs.onError(err instanceof Error ? err.message : 'Kunde inte starta byggsekvensen.') return } if (!response.ok || !response.body) { cbs.onError(`HTTP ${response.status}: kunde inte starta byggsekvensen.`) return } const reader = response.body.getReader() const decoder = new TextDecoder() let buffer = '' try { while (true) { const { done, value } = await reader.read() if (done) break buffer += decoder.decode(value, { stream: true }) let newlineIdx: number while ((newlineIdx = buffer.indexOf('\n')) >= 0) { const line = buffer.slice(0, newlineIdx).trim() buffer = buffer.slice(newlineIdx + 1) if (!line) continue try { const event = JSON.parse(line) as | { step: StepId; status: StepStatus } | { step: 'finalize'; status: 'success'; profile: ProfilePayload } | { step: 'error'; status: 'error'; message: string } | { step: 'prewarm'; status: StepStatus } if (event.step === 'error') { cbs.onError(event.message || 'Okänt fel under byggsekvensen.') continue } if (event.step === 'prewarm') { // Pre-warm runs after finalize: surface nothing in the UI; the // user has already moved to Phase B by then. continue } cbs.setStatus(event.step, event.status as StepStatus) if (event.step === 'finalize' && 'profile' in event) { cbs.onProfile(event.profile) cbs.onComplete() } } catch { // Malformed JSON line: keep reading. } } } } catch (err) { if (!signal.aborted) { cbs.onError(err instanceof Error ? err.message : 'Streamen avbröts.') } } finally { try { reader.releaseLock() } catch { // Already released } } }