Files
accounted/components/onboarding/NewUserChecklist.tsx
T
Jakob Wennberg 9dfa6c6708 feat(home): first-run block as a numbered four-step thread with partner marks (#1149)
* feat(home): first-run block as a numbered three-step thread with partner marks

The founder-picked stepped shape for 'Hur vill du komma igang?':
1 Fa in din bokforing (primary Flytta bokforingen + Fortnox/Visma/Bokio
marks + '+ SIE'; Starta fran borjan as an inline alternative that just
checks the step off), 2 Koppla banken (Enable Banking mark only),
3 Bygg din bokforingsassistent (Beta chip, no vendor logo). Dots walk
number -> filled active -> sage check; the persisted state machine is
unchanged, but choosing a path no longer auto-completes the setup:
the block retires when all three steps are done (or via Dolj).
DashboardContent's build-assistant hero now waits until the checklist
is gone so the assistant is not pitched twice. initial_setup i18n
rewritten for the stepped copy (sv+en), unused selected-state keys
removed.

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

* feat(home): Skatteverket as step three, assistant last, ticked steps collapse

Founder feedback on #1149: the thread is now four steps: 1 Fa in din
bokforing, 2 Koppla banken, 3 Anslut Skatteverket (with the SKV mark,
BankID authorize link; skipped entirely in builds without the
skatteverket extension), 4 Bygg din bokforingsassistent. A completed
step drops its description and actions and keeps only the checked
muted title, so the fresh-start pitch never lingers after the books
are in. The heading counts honestly ({count} steg) and completion now
requires all four steps.

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

* polish(home): one-line checklist header

Founder feedback: the title and sub-line said the same thing twice;
only '4 steg sa ar bokforingen igang' remains (Dolj stays beside it).

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 16:37:51 +02:00

312 lines
11 KiB
TypeScript

'use client'
import { useEffect, useState } from 'react'
import { useRouter } from 'next/navigation'
import { useTranslations } from 'next-intl'
import { Check } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
import { useErrorToast } from '@/lib/hooks/use-error-toast'
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
hasAgentBuilt?: boolean
}
/**
* First-run getting-started block on Hem, in the founder-picked stepped
* shape: a numbered three-step thread (get the books in, connect the bank,
* build the assistant) with the partner marks on the steps that have them.
* 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,
hasAgentBuilt = false,
}: NewUserChecklistProps) {
const t = useTranslations('initial_setup')
const router = useRouter()
const showError = useErrorToast()
const hasAi = useCapability(CAPABILITY.ai)
const [state, setState] = useState(initialState)
const [saving, setSaving] = useState<InitialSetupPath | 'dismiss' | 'complete' | null>(null)
const hasMigration = ENABLED_EXTENSION_IDS.has('arcim-migration')
const hasBanking = ENABLED_EXTENSION_IDS.has('enable-banking')
const hasSkatteverket = ENABLED_EXTENSION_IDS.has('skatteverket')
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 extension skip that step.
const step3Done = !hasSkatteverket || hasSkatteverketConnected
const step4Done = hasAgentBuilt
useEffect(() => {
// The block retires itself once every step is done; Dölj remains the
// manual way out.
if (!state.completedAt && step1Done && step2Done && step3Done && step4Done && saving === null) {
void persist({ completed: true }, 'complete')
}
// 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, saving, state.completedAt])
if (state.dismissedAt || state.completedAt) return null
const goMigration = async () => {
const updated = await persist({ path: 'migration' }, 'migration')
if (updated) router.push(hasMigration ? '/import?mode=migration' : '/import?mode=sie')
}
const goFresh = () => void persist({ path: 'fresh' }, 'fresh')
const goBank = async () => {
const updated = await persist({ path: state.path ?? 'bank' }, 'bank')
if (updated) router.push(hasBanking ? '/import?mode=psd2' : '/import?mode=bank')
}
const activeStep = !step1Done ? 1 : !step2Done ? 2 : !step3Done ? 3 : 4
return (
<section className={className} aria-label={t('title', { count: hasSkatteverket ? 4 : 3 })}>
<div className="mb-1 flex items-center justify-between gap-3">
<h2 className="text-sm font-medium">{t('title', { count: hasSkatteverket ? 4 : 3 })}</h2>
<button
type="button"
disabled={saving !== null}
onClick={() => void persist({ dismissed: true }, 'dismiss')}
className="shrink-0 text-xs text-muted-foreground underline decoration-border underline-offset-4 transition-colors hover:text-foreground"
>
{t('dismiss')}
</button>
</div>
<div className="mt-4">
<Step
number={1}
done={step1Done}
active={activeStep === 1}
title={t('step_books_title')}
>
<p className="text-xs leading-5 text-muted-foreground">
{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')}
</p>
<div className="mt-3 flex flex-wrap items-center gap-3">
<Button
size="sm"
disabled={saving !== null}
onClick={() => void goMigration()}
>
{t('step_books_action')}
</Button>
<span className="flex items-center gap-1.5">
<LogoMark src="/logos/fortnox.svg" name="Fortnox" />
<LogoMark src="/logos/visma.jpeg" name="Visma" />
<LogoMark src="/logos/bokio.png" name="Bokio" />
<span className="ml-1 text-[11px] text-muted-foreground">{t('step_books_sie')}</span>
</span>
</div>
</Step>
<Step
number={2}
done={step2Done}
active={activeStep === 2}
title={t('step_bank_title')}
>
<p className="text-xs leading-5 text-muted-foreground">{t('step_bank_description')}</p>
<div className="mt-3 flex flex-wrap items-center gap-3">
<Button
size="sm"
variant={activeStep === 2 ? 'default' : 'outline'}
disabled={saving !== null}
onClick={() => void goBank()}
>
{t('step_bank_action')}
</Button>
{hasBanking && (
<LogoMark
src="/logos/enable-banking-icon.png"
name="Enable Banking"
mono
/>
)}
</div>
</Step>
{hasSkatteverket && (
<Step
number={3}
done={step3Done}
active={activeStep === 3}
title={t('step_skv_title')}
>
<p className="text-xs leading-5 text-muted-foreground">{t('step_skv_description')}</p>
<div className="mt-3 flex flex-wrap items-center gap-3">
<Button
size="sm"
variant={activeStep === 3 ? 'default' : 'outline'}
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=/">
{t('step_skv_action')}
</a>
</Button>
<LogoMark src="/logos/skatteverket_color.svg" name="Skatteverket" />
</div>
</Step>
)}
<Step
number={hasSkatteverket ? 4 : 3}
done={step4Done}
active={activeStep === 4 || (!hasSkatteverket && activeStep === 3)}
title={t('step_assistant_title')}
badge={t('step_assistant_beta')}
last
>
<p className="text-xs leading-5 text-muted-foreground">{t('step_assistant_description')}</p>
<div className="mt-3">
<Button
size="sm"
variant={activeStep >= 3 ? 'default' : 'outline'}
onClick={() => router.push(hasAi ? '/onboarding/agent' : '/settings/billing')}
>
{t('step_assistant_action')}
</Button>
</div>
</Step>
</div>
</section>
)
}
/** One numbered step on the thread: dot + hairline down to the next step. */
function Step({
number,
done,
active,
title,
badge,
last = false,
children,
}: {
number: number
done: boolean
active: boolean
title: string
badge?: string
last?: boolean
children: React.ReactNode
}) {
return (
<div className="relative flex gap-4 pb-6 last:pb-0">
{!last && (
<span
className="absolute bottom-0 left-[13px] top-8 w-px bg-border"
aria-hidden="true"
/>
)}
<span
className={cn(
'relative z-10 flex h-7 w-7 shrink-0 items-center justify-center rounded-full border text-xs tabular-nums',
done
? 'border-success/40 text-success'
: active
? '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 pt-1">
<p className={cn('flex items-center gap-2 text-sm', active && !done && 'font-medium', done && 'text-muted-foreground')}>
{title}
{badge && (
<span className="rounded-full border border-border px-2 py-0.5 text-[10px] leading-none text-muted-foreground">
{badge}
</span>
)}
</p>
{/* A ticked step needs no pitch: the description and actions only
render while the step is still open. */}
{!done && <div className="mt-1">{children}</div>}
</div>
</div>
)
}
/** 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>
)
}