From 741cdcc60eb0a86082175dc21989ff4f7723f5d7 Mon Sep 17 00:00:00 2001 From: Jakob Wennberg Date: Mon, 10 Aug 2026 09:57:38 +0200 Subject: [PATCH] feat(onboarding): completion signature when the checklist finishes (#1487) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 * 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 --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 --- components/onboarding/NewUserChecklist.tsx | 61 ++++++++++++++++++++-- messages/en.json | 1 + messages/sv.json | 1 + 3 files changed, 58 insertions(+), 5 deletions(-) diff --git a/components/onboarding/NewUserChecklist.tsx b/components/onboarding/NewUserChecklist.tsx index d2cd2084..e8efad56 100644 --- a/components/onboarding/NewUserChecklist.tsx +++ b/components/onboarding/NewUserChecklist.tsx @@ -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(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 ( +
+
+ {/* The orb draws at the top quarter of its canvas (CY = height/4), + so a 100px canvas clipped to 52px shows exactly the check. */} + +

{t('completed_verdict')}

+
+
+ ) + } 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 (
diff --git a/messages/en.json b/messages/en.json index 3c36fbe6..7c427cac 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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?", diff --git a/messages/sv.json b/messages/sv.json index 325d48bd..e263b5c7 100644 --- a/messages/sv.json +++ b/messages/sv.json @@ -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?",