feat(onboarding): completion signature when the checklist finishes (#1487)

* feat(onboarding): completion signature when the checklist finishes

The getting-started block used to vanish the instant its last step
completed: a useEffect PATCHed completed:true and the component returned
null, so the arc's payoff was a silent disappearance.

Now the session that finishes the last step gets the completion beat the
activation analysis prescribed: the JourneyOrb check-morph (same particle
idiom as the journey, deliberately not confetti) with one verdict line,
'Bokföringen är igång.', held for a beat before the block fades and
retires. The beat latches via a ref so a failed completion PATCH can
retry without replaying it, and the PATCH still fires first so completion
is durable even if the user navigates away mid-beat. Companies whose
completedAt arrives from the server never see the beat: it plays exactly
once, in the finishing session.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(onboarding): hold the retired state while the completion PATCH is in flight

PR Agent on #1487: if the completed-PATCH takes longer than the 3.2s
beat, retiring fell back to null with completedAt still unset, so the
full checklist flashed back in after the verdict had already played. The
beat now ends in a terminal 'done' phase that keeps the block retired
for the rest of the session; a failed PATCH keeps retrying invisibly and
the next visit renders from server truth either way.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-08-10 09:57:38 +02:00
committed by GitHub
co-authored by Claude Fable 5 Jakob Wennberg
parent d1b938d869
commit 741cdcc60e
3 changed files with 58 additions and 5 deletions
+56 -5
View File
@@ -1,6 +1,6 @@
'use client'
import { useEffect, useState } from 'react'
import { useEffect, useRef, useState } from 'react'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
import { useTranslations } from 'next-intl'
@@ -8,6 +8,7 @@ import { Check } from 'lucide-react'
import posthog from 'posthog-js'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import JourneyOrb from '@/components/onboarding/journey/JourneyOrb'
import { cn } from '@/lib/utils'
import { useErrorToast } from '@/lib/hooks/use-error-toast'
import { useFormat } from '@/lib/hooks/use-format'
@@ -76,6 +77,12 @@ export default function NewUserChecklist({
const hasAi = useCapability(CAPABILITY.ai)
const [state, setState] = useState(initialState)
const [saving, setSaving] = useState<InitialSetupPath | 'dismiss' | 'complete' | null>(null)
// The completion signature: 'verdict' shows the orb check-morph and the
// verdict line, 'closing' fades the block, 'done' keeps it retired for the
// rest of the session. Plays only in the session that finishes the last
// step (companies whose completedAt arrives from the server never see it).
const [retiring, setRetiring] = useState<'verdict' | 'closing' | 'done' | null>(null)
const retireStartedRef = useRef(false)
const hasMigration = ENABLED_EXTENSION_IDS.has('arcim-migration')
const hasBanking = ENABLED_EXTENSION_IDS.has('enable-banking')
@@ -118,12 +125,17 @@ export default function NewUserChecklist({
useEffect(() => {
// The block retires itself once every step is done; Dölj remains the
// manual way out.
// manual way out. The signature beat latches via retireStartedRef so a
// failed persist can retry the PATCH without replaying the beat.
if (
!state.completedAt &&
step1Done && step2Done && step3Done && step4Done && step5Done &&
saving === null
) {
if (!retireStartedRef.current) {
retireStartedRef.current = true
setRetiring('verdict')
}
void persist({ completed: true }, 'complete').then((updated) => {
if (updated) captureSetup('onboarding_setup_completed', { path: updated.path })
})
@@ -133,7 +145,48 @@ export default function NewUserChecklist({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [step1Done, step2Done, step3Done, step4Done, step5Done, saving, state.completedAt])
if (state.dismissedAt || state.completedAt) return null
// Beat timing: hold the verdict, then fade, then stay retired.
useEffect(() => {
if (retiring !== 'verdict') return
const toClosing = window.setTimeout(() => setRetiring('closing'), 2600)
const toGone = window.setTimeout(() => setRetiring('done'), 3200)
return () => {
window.clearTimeout(toClosing)
window.clearTimeout(toGone)
}
}, [retiring])
const numbers = checklistNumbers({ hasSkatteverket, hasInbox })
const stepCount = numbers.count
if (state.dismissedAt) return null
// After the beat, stay retired even while the completion PATCH is still in
// flight or retrying: falling through to the full checklist here would
// flash it after the verdict already played. A failed PATCH keeps retrying
// invisibly; the next visit renders from server truth either way.
if (retiring === 'done') return null
if (state.completedAt && !retiring) return null
if (retiring) {
return (
<section className={className} aria-label={t('title', { count: stepCount })}>
<div
role="status"
className={cn(
'flex flex-col items-center py-4 text-center transition-opacity duration-500',
retiring === 'closing' ? 'opacity-0' : 'opacity-100',
)}
>
{/* The orb draws at the top quarter of its canvas (CY = height/4),
so a 100px canvas clipped to 52px shows exactly the check. */}
<div className="relative h-[52px] w-28 overflow-hidden" aria-hidden="true">
<JourneyOrb state="check" targetX={0.5} height={100} />
</div>
<p className="mt-2 text-sm font-medium">{t('completed_verdict')}</p>
</div>
</section>
)
}
const goMigration = async () => {
const updated = await persist({ path: 'migration' }, 'migration')
@@ -167,8 +220,6 @@ export default function NewUserChecklist({
})
const activeStep = !step1Done ? 1 : !step2Done ? 2 : !step3Done ? 3 : !step4Done ? 4 : 5
const numbers = checklistNumbers({ hasSkatteverket, hasInbox })
const stepCount = numbers.count
return (
<section className={className} aria-label={t('title', { count: stepCount })}>
+1
View File
@@ -1336,6 +1336,7 @@
"toast_status_update_failed": "Could not update status"
},
"initial_setup": {
"completed_verdict": "Your bookkeeping is up and running.",
"title": "{count} steps and your bookkeeping is running",
"step_books_title": "Get your books in",
"step_books_description": "Import from another system or upload a SIE file. New business?",
+1
View File
@@ -1336,6 +1336,7 @@
"toast_status_update_failed": "Kunde inte uppdatera status"
},
"initial_setup": {
"completed_verdict": "Bokföringen är igång.",
"title": "{count} steg så är bokföringen igång",
"step_books_title": "Få in din bokföring",
"step_books_description": "Importera från ett annat system eller ladda upp en SIE-fil. Ny verksamhet?",