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>
This commit is contained in:
Jakob Wennberg
2026-07-24 16:37:51 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 63fd5311ed
commit 9dfa6c6708
5 changed files with 268 additions and 147 deletions
+4 -1
View File
@@ -87,7 +87,10 @@ export const PATCH = withRouteContext(
const update: Record<string, unknown> = {}
if (body.path !== undefined) {
update.initial_setup_path = body.path
update.initial_setup_completed_at = body.path === 'fresh' ? now : null
// Choosing a path no longer completes the setup: since the stepped
// Hem block (rest-of-nav 2), "fresh" just checks off step one and the
// block completes when every step is done.
update.initial_setup_completed_at = null
update.initial_setup_dismissed_at = null
}
if (body.completed !== undefined) {
+4 -2
View File
@@ -101,8 +101,10 @@ export default function DashboardContent({
{/* Build-assistant hero: shown only until the company has a verified
agent_profile, so existing/migrated users get a clear prompt instead
of a full-screen onboarding takeover. */}
{!agentBuilt && (
of a full-screen onboarding takeover. While the stepped first-run
checklist is visible it already carries the assistant as step 3, so
the hero waits until that block is dismissed or completed. */}
{!agentBuilt && (initialSetup.dismissedAt || initialSetup.completedAt) && (
<section>
{/* Non-payers keep seeing the hero (conversion surface) but it
routes to billing instead of a build flow that would 403. */}
+226 -106
View File
@@ -2,18 +2,9 @@
import { useEffect, useState } from 'react'
import { useRouter } from 'next/navigation'
import Link from 'next/link'
import { useTranslations } from 'next-intl'
import {
ArrowRight,
ArrowRightLeft,
FileCheck,
Landmark,
MessageCircle,
Sparkles,
} from 'lucide-react'
import { Check } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { cn } from '@/lib/utils'
import { useErrorToast } from '@/lib/hooks/use-error-toast'
import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions'
@@ -30,12 +21,14 @@ interface NewUserChecklistProps {
hasAgentBuilt?: boolean
}
const pathIcons = {
migration: ArrowRightLeft,
bank: Landmark,
fresh: Sparkles,
} as const
/**
* 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,
@@ -81,111 +74,238 @@ export default function NewUserChecklist({
}
}
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(() => {
const selectedPathComplete =
(state.path === 'migration' && hasBookkeepingImported) ||
(state.path === 'bank' && hasBankConnected)
if (!state.completedAt && selectedPathComplete && saving === null) {
// 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
}, [hasBankConnected, hasBookkeepingImported, saving, state.completedAt, state.path])
}, [step1Done, step2Done, step3Done, step4Done, saving, state.completedAt])
if (state.dismissedAt || state.completedAt) return null
const choosePath = async (path: InitialSetupPath) => {
const updated = await persist({ path }, path)
if (!updated || path === 'fresh') return
if (path === 'migration') {
router.push(hasMigration ? '/import?mode=migration' : '/import?mode=sie')
} else {
router.push(hasBanking ? '/import?mode=psd2' : '/import?mode=bank')
}
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')
}
if (!state.path) {
return (
<Card className={cn('border-foreground/20', className)}>
<CardHeader className="pb-4">
<CardTitle className="text-lg">{t('title')}</CardTitle>
<CardDescription>{t('description')}</CardDescription>
</CardHeader>
<CardContent className="grid gap-3 md:grid-cols-3">
{(['migration', 'bank', 'fresh'] as const).map((path) => {
const Icon = pathIcons[path]
return (
<button
key={path}
type="button"
disabled={saving !== null}
onClick={() => void choosePath(path)}
className="group min-h-28 rounded-lg border border-border p-4 text-left transition-colors hover:border-foreground/40 hover:bg-secondary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50"
>
<div className="flex items-center justify-between gap-3">
<Icon className="h-5 w-5" />
<ArrowRight className="h-4 w-4 text-muted-foreground transition-transform group-hover:translate-x-0.5" />
</div>
<p className="mt-4 text-sm font-medium">{t(`${path}_title`)}</p>
<p className="mt-1 text-xs leading-relaxed text-muted-foreground">
{t(`${path}_description`)}
</p>
</button>
)
})}
</CardContent>
</Card>
)
}
const primaryHref = state.path === 'migration'
? (hasMigration ? '/import?mode=migration' : '/import?mode=sie')
: (hasBanking ? '/import?mode=psd2' : '/import?mode=bank')
const PrimaryIcon = pathIcons[state.path]
const activeStep = !step1Done ? 1 : !step2Done ? 2 : !step3Done ? 3 : 4
return (
<Card className={cn('border-foreground/20', className)}>
<CardHeader className="pb-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div>
<CardTitle className="text-lg">{t(`${state.path}_selected_title`)}</CardTitle>
<CardDescription className="mt-1">{t(`${state.path}_selected_description`)}</CardDescription>
<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>
<Button
variant="ghost"
size="sm"
disabled={saving !== null}
onClick={() => void persist({ dismissed: true }, 'dismiss')}
</Step>
{hasSkatteverket && (
<Step
number={3}
done={step3Done}
active={activeStep === 3}
title={t('step_skv_title')}
>
{t('dismiss')}
</Button>
</div>
</CardHeader>
<CardContent className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<Button asChild>
<Link href={primaryHref}>
<PrimaryIcon className="mr-2 h-4 w-4" />
{t(`${state.path}_action`)}
</Link>
</Button>
<div className="flex flex-wrap items-center gap-x-4 gap-y-2 text-xs text-muted-foreground">
{hasSkatteverket && !hasSkatteverketConnected && (
// 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=/" className="inline-flex min-h-10 items-center hover:text-foreground">
<FileCheck className="mr-2 h-4 w-4" />
{t('optional_skatteverket')}
</a>
<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>
)}
{!hasAgentBuilt && (
<Link href={hasAi ? '/onboarding/agent' : '/settings/billing'} className="inline-flex min-h-10 items-center hover:text-foreground">
<MessageCircle className="mr-2 h-4 w-4" />
{t('optional_assistant')}
</Link>
)}
</div>
</CardContent>
</Card>
</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>
)
}
+17 -19
View File
@@ -1132,26 +1132,24 @@
"security_note": "Your data is encrypted and stored securely in Sweden"
},
"initial_setup": {
"title": "How would you like to get started?",
"description": "Choose the path that gets you to value fastest. You can switch or do the rest later.",
"migration_title": "Move existing books",
"migration_description": "Import from another system or upload a SIE file.",
"bank_title": "Connect your bank",
"bank_description": "Fetch transactions automatically or import a bank statement.",
"fresh_title": "Start from scratch",
"fresh_description": "Continue to the overview without prior bookkeeping.",
"migration_selected_title": "Move your books",
"migration_selected_description": "Import your history first. The overview stays available while you work.",
"migration_action": "Open import",
"bank_selected_title": "Connect your bank",
"bank_selected_description": "Fetch transactions so you can start reconciling and bookkeeping.",
"bank_action": "Connect or import bank",
"fresh_selected_title": "You are ready",
"fresh_selected_description": "Start working directly from the overview.",
"fresh_action": "Continue",
"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?",
"step_books_fresh_link": "Start from scratch",
"step_books_fresh_suffix": "instead.",
"step_books_action": "Move the books",
"step_books_sie": "+ SIE",
"step_bank_title": "Connect the bank",
"step_bank_description": "Fetch transactions automatically, or import a bank statement.",
"step_bank_action": "Connect the bank",
"step_assistant_title": "Build your bookkeeping assistant",
"step_assistant_beta": "Beta",
"step_assistant_description": "A few questions about your business calibrate an assistant that suggests bookkeeping for you.",
"step_assistant_action": "Get started",
"dismiss": "Hide",
"optional_skatteverket": "Connect Skatteverket, optional",
"optional_assistant": "Create assistant, optional"
"step_skv_title": "Connect Skatteverket",
"step_skv_description": "See the tax account and file VAT and employer declarations right from here. Connects with BankID in a couple of minutes.",
"step_skv_action": "Connect Skatteverket"
},
"tax_assessment_notices": {
"title": "Final tax notices and remaining tax",
+17 -19
View File
@@ -1132,26 +1132,24 @@
"security_note": "Din data är krypterad och lagras säkert i Sverige"
},
"initial_setup": {
"title": "Hur vill du komma igång?",
"description": "Välj den väg som ger dig första nyttan snabbast. Du kan alltid byta eller göra resten senare.",
"migration_title": "Flytta befintlig bokföring",
"migration_description": "Importera från ett annat system eller ladda upp en SIE-fil.",
"bank_title": "Koppla banken",
"bank_description": "Hämta transaktioner automatiskt eller importera ett kontoutdrag.",
"fresh_title": "Starta från början",
"fresh_description": "Fortsätt direkt till översikten utan tidigare bokföring.",
"migration_selected_title": "Flytta in din bokföring",
"migration_selected_description": "Importera historiken först. Översikten är tillgänglig under tiden.",
"migration_action": "Öppna importen",
"bank_selected_title": "Koppla din bank",
"bank_selected_description": "Hämta transaktionerna så att du kan börja stämma av och bokföra.",
"bank_action": "Koppla eller importera bank",
"fresh_selected_title": "Du är redo",
"fresh_selected_description": "Börja arbeta direkt i översikten.",
"fresh_action": "Fortsätt",
"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?",
"step_books_fresh_link": "Starta från början",
"step_books_fresh_suffix": "i stället.",
"step_books_action": "Flytta bokföringen",
"step_books_sie": "+ SIE",
"step_bank_title": "Koppla banken",
"step_bank_description": "Hämta transaktioner automatiskt, eller importera ett kontoutdrag.",
"step_bank_action": "Koppla banken",
"step_assistant_title": "Bygg din bokföringsassistent",
"step_assistant_beta": "Beta",
"step_assistant_description": "Några frågor om din verksamhet kalibrerar en assistent som föreslår bokföring åt dig.",
"step_assistant_action": "Kom igång",
"dismiss": "Dölj",
"optional_skatteverket": "Anslut Skatteverket, valfritt",
"optional_assistant": "Skapa assistent, valfritt"
"step_skv_title": "Anslut Skatteverket",
"step_skv_description": "Se skattekontot och lämna moms- och arbetsgivardeklarationer direkt härifrån. Ansluts med BankID på ett par minuter.",
"step_skv_action": "Anslut Skatteverket"
},
"tax_assessment_notices": {
"title": "Slutskattebesked och kvarskatt",