Files
accounted/components/onboarding/NewUserChecklist.tsx
T
Jakob Wennberg 741cdcc60e 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>
2026-08-10 09:57:38 +02:00

494 lines
18 KiB
TypeScript

'use client'
import { useEffect, useRef, useState } from 'react'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
import { useTranslations } from 'next-intl'
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'
import { isAnalyticsEnabled } from '@/lib/analytics/enabled'
import { checklistNumbers, type VatDeadlineLine } from '@/lib/onboarding/checklist'
import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions'
import { useCapability } from '@/contexts/CompanyContext'
import { CAPABILITY } from '@/lib/entitlements/keys'
import type { InitialSetupPath, InitialSetupState } from '@/types'
interface NewUserChecklistProps {
initialState: InitialSetupState
className?: string
hasBookkeepingImported?: boolean
hasBankConnected?: boolean
hasSkatteverketConnected?: boolean
hasInboxItems?: boolean
hasAgentBuilt?: boolean
/** Personalized VAT-deadline line for the Skatteverket step (null = say nothing). */
vatLine?: VatDeadlineLine
}
/**
* Activation funnel events, mirroring the one existing product-event site
* (lib/support/submit-feedback.ts): guarded, try/caught, no PII in
* properties. Sandbox companies never render this block (their
* initial_setup is seeded completed+dismissed), so no sandbox gate needed.
*/
function captureSetup(event: string, properties?: Record<string, unknown>) {
if (!isAnalyticsEnabled()) return
try {
posthog.capture(event, properties)
} catch {
// Telemetry must never affect the checklist.
}
}
/**
* First-run getting-started block on Hem, in the founder-picked stepped
* shape: a numbered thread (get the books in, connect the bank, connect
* Skatteverket, get receipts flowing, build the assistant) on a hairline
* spine.
* Only the step you are on argues its case: it carries the description and
* the partner marks next to a filled action. Steps you have not reached yet
* drop the pitch but keep a quiet outline action, so any step stays one
* click away (connect Skatteverket before the bank, if that is your order).
* Done steps collapse to their title.
* The persisted state machine is unchanged (path / completedAt /
* dismissedAt via /api/onboarding/state); "Starta från början" records the
* fresh path and simply checks off step one.
*/
export default function NewUserChecklist({
initialState,
className,
hasBookkeepingImported = false,
hasBankConnected = false,
hasSkatteverketConnected = false,
hasInboxItems = false,
hasAgentBuilt = false,
vatLine = null,
}: NewUserChecklistProps) {
const t = useTranslations('initial_setup')
const router = useRouter()
const showError = useErrorToast()
const { formatDateLong } = useFormat()
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')
const hasSkatteverket = ENABLED_EXTENSION_IDS.has('skatteverket')
const hasInbox = ENABLED_EXTENSION_IDS.has('invoice-inbox')
const hasWhatsApp = ENABLED_EXTENSION_IDS.has('whatsapp-inbox')
const persist = async (
body: Record<string, unknown>,
pending: InitialSetupPath | 'dismiss' | 'complete',
): Promise<InitialSetupState | null> => {
setSaving(pending)
try {
const response = await fetch('/api/onboarding/state', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
if (!response.ok) {
await showError(response, { context: 'settings' })
return null
}
const payload = await response.json() as { data: InitialSetupState }
setState(payload.data)
return payload.data
} catch (error) {
await showError(error, { context: 'settings' })
return null
} finally {
setSaving(null)
}
}
const step1Done = hasBookkeepingImported || state.path === 'fresh'
const step2Done = hasBankConnected
// Companies built without the skatteverket/inbox extensions skip those steps.
const step3Done = !hasSkatteverket || hasSkatteverketConnected
const step4Done = !hasInbox || hasInboxItems
const step5Done = hasAgentBuilt
useEffect(() => {
// The block retires itself once every step is done; Dölj remains the
// 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 })
})
}
// persist intentionally stays out: its identity follows the toast hook and
// would retrigger this completion sync after every render.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [step1Done, step2Done, step3Done, step4Done, step5Done, saving, state.completedAt])
// 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')
if (updated) {
captureSetup('onboarding_setup_step_started', { step: 'books', path: 'migration' })
router.push(hasMigration ? '/import?mode=migration' : '/import?mode=sie')
}
}
const goFresh = () =>
void persist({ path: 'fresh' }, 'fresh').then((updated) => {
if (updated) captureSetup('onboarding_setup_step_started', { step: 'books', path: 'fresh' })
})
const goBank = async () => {
const updated = await persist({ path: state.path ?? 'bank' }, 'bank')
if (updated) {
captureSetup('onboarding_setup_step_started', { step: 'bank' })
router.push(hasBanking ? '/import?mode=psd2' : '/import?mode=bank')
}
}
const goReceipts = () => {
captureSetup('onboarding_setup_step_started', { step: 'receipts' })
router.push(hasAi ? '/e/general/invoice-inbox' : '/settings/billing')
}
const goAssistant = () => {
captureSetup('onboarding_setup_step_started', { step: 'assistant' })
router.push(hasAi ? '/onboarding/agent' : '/settings/billing')
}
const dismiss = () =>
void persist({ dismissed: true }, 'dismiss').then((updated) => {
if (updated) captureSetup('onboarding_setup_dismissed', {})
})
const activeStep = !step1Done ? 1 : !step2Done ? 2 : !step3Done ? 3 : !step4Done ? 4 : 5
return (
<section className={className} aria-label={t('title', { count: stepCount })}>
<div className="flex items-center justify-between gap-3">
<h2 className="text-sm font-medium">{t('title', { count: stepCount })}</h2>
<button
type="button"
disabled={saving !== null}
onClick={dismiss}
className="shrink-0 text-xs text-muted-foreground underline decoration-border underline-offset-4 transition-colors hover:text-foreground"
>
{t('dismiss')}
</button>
</div>
<ol className="mt-3" role="list">
<Step
number={1}
done={step1Done}
active={activeStep === 1}
title={t('step_books_title')}
action={(variant) => (
<Button
size="sm"
variant={variant}
disabled={saving !== null}
onClick={() => void goMigration()}
>
{t('step_books_action')}
</Button>
)}
marks={
<>
<LogoMark src="/logos/fortnox.svg" name="Fortnox" />
<LogoMark src="/logos/visma.jpeg" name="Visma" />
<LogoMark src="/logos/bokio.png" name="Bokio" />
<span className="text-[11px] text-muted-foreground">{t('step_books_sie')}</span>
</>
}
>
{t('step_books_description')}{' '}
<button
type="button"
disabled={saving !== null}
onClick={goFresh}
className="underline decoration-border underline-offset-4 transition-colors hover:text-foreground"
>
{t('step_books_fresh_link')}
</button>{' '}
{t('step_books_fresh_suffix')}
</Step>
<Step
number={2}
done={step2Done}
active={activeStep === 2}
title={t('step_bank_title')}
action={(variant) => (
<Button
size="sm"
variant={variant}
disabled={saving !== null}
onClick={() => void goBank()}
>
{t('step_bank_action')}
</Button>
)}
marks={
hasBanking ? (
<LogoMark src="/logos/enable-banking-icon.png" name="Enable Banking" mono />
) : undefined
}
>
{t('step_bank_description')}
</Step>
{hasSkatteverket && (
<Step
number={numbers.skv}
done={step3Done}
active={activeStep === 3}
title={t('step_skv_title')}
action={(variant) => (
<Button size="sm" variant={variant} asChild>
{/* The authorize endpoint redirects off-site to Skatteverket. */}
{/* eslint-disable-next-line @next/next/no-html-link-for-pages */}
<a
href="/api/extensions/ext/skatteverket/authorize?return_to=/"
onClick={() => captureSetup('onboarding_setup_step_started', { step: 'skatteverket' })}
>
{t('step_skv_action')}
</a>
</Button>
)}
marks={<LogoMark src="/logos/skatteverket_color.svg" name="Skatteverket" />}
>
{t('step_skv_description')}
{vatLine?.kind === 'date' && (
<>
{' '}
<span className="text-foreground">
{t('step_skv_next_vat', { date: formatDateLong(vatLine.dueDate) })}
</span>
</>
)}
{vatLine?.kind === 'missing_period' && (
<>
{' '}
<Link
href="/settings/tax"
className="underline decoration-border underline-offset-4 transition-colors hover:text-foreground"
>
{t('step_skv_choose_period')}
</Link>
</>
)}
</Step>
)}
{hasInbox && (
<Step
number={numbers.receipts}
done={step4Done}
active={activeStep === 4}
title={t('step_receipts_title')}
action={(variant) => (
<Button size="sm" variant={variant} onClick={goReceipts}>
{t('step_receipts_action')}
</Button>
)}
marks={
<span className="text-[11px] text-muted-foreground">
{hasWhatsApp ? t('step_receipts_channels') : t('step_receipts_channels_no_wa')}
</span>
}
>
{t('step_receipts_description')}
</Step>
)}
<Step
number={numbers.assistant}
done={step5Done}
active={activeStep === 5}
title={t('step_assistant_title')}
badge={t('step_assistant_beta')}
last
action={(variant) => (
<Button size="sm" variant={variant} onClick={goAssistant}>
{t('step_assistant_action')}
</Button>
)}
>
{t('step_assistant_description')}
</Step>
</ol>
</section>
)
}
/**
* One numbered step on the thread: dot + hairline down to the next step.
* `children` (the pitch) and `marks` (partner logos) render only while this
* is the step the user is on. `action` renders on every step that is not
* done: filled on the active step, outline on the ones further down, so no
* step is ever unreachable just because it is not next in line.
* The title row is a constant 36px so it matches the h-9 action button and
* the dot (mt-1) centres in it; the spine then runs dot-bottom to dot-top
* (top-8 to -bottom-1) without visible breaks.
*/
function Step({
number,
done,
active,
title,
badge,
action,
marks,
last = false,
children,
}: {
number: number
done: boolean
active: boolean
title: string
badge?: string
action?: (variant: 'default' | 'outline') => React.ReactNode
marks?: React.ReactNode
last?: boolean
children: React.ReactNode
}) {
const open = active && !done
return (
<li
className="relative flex gap-3 pb-3 last:pb-0"
aria-current={open ? 'step' : undefined}
>
{!last && (
<span
className="absolute -bottom-1 left-[13px] top-8 w-px bg-border"
aria-hidden="true"
/>
)}
<span
className={cn(
'relative z-10 mt-1 flex h-7 w-7 shrink-0 items-center justify-center rounded-full border text-xs tabular-nums',
done
? 'border-success/40 text-success'
: open
? 'border-foreground bg-foreground text-background'
: 'border-border text-muted-foreground',
)}
>
{done ? <Check className="h-3.5 w-3.5" aria-hidden="true" /> : number}
</span>
<div className="min-w-0 flex-1">
<div className="flex min-h-9 flex-wrap items-center gap-x-3 gap-y-2">
<div
className={cn(
'flex items-center gap-2 text-sm',
open ? 'font-medium' : 'text-muted-foreground',
)}
>
<span>{title}</span>
{badge && (
<Badge
variant="secondary"
className={cn('shrink-0 uppercase tracking-wider', !open && 'font-normal')}
>
{badge}
</Badge>
)}
</div>
{!done && action?.(open ? 'default' : 'outline')}
</div>
{open && (
<div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-2">
<p className="text-xs leading-5 text-muted-foreground">{children}</p>
{marks && <span className="flex items-center gap-2">{marks}</span>}
</div>
)}
</div>
</li>
)
}
/** Tiny partner mark: real logo on a white chip; `mono` darkens a white
* source logo in light mode and lifts it in dark (Enable Banking). */
function LogoMark({ src, name, mono = false }: { src: string; name: string; mono?: boolean }) {
return (
<span
className={cn(
'flex h-6 w-6 items-center justify-center overflow-hidden rounded-md border border-border',
!mono && 'bg-white',
)}
title={name}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={src}
alt={name}
className={cn(
'h-4 w-4 object-contain',
mono &&
'opacity-90 [filter:grayscale(100%)_brightness(0.18)] dark:[filter:grayscale(100%)_brightness(1.5)]',
)}
/>
</span>
)
}