feat(onboarding): branch question on the journey done screen (#1468)

* feat(onboarding): branch question on the journey done screen

Second slice of the approved activation concept: the moment the company
exists, the done screen asks "Var fanns bokföringen innan?" with
provider chips (real logos), SIE file, and new-business options, plus a
quiet look-around escape. Choices persist initial_setup_path
(fire-and-forget) and deep-link into the existing flows: providers jump
straight to the migration wizard's connect step (sieViaApi providers
only; Visma/Bokio land on the provider list where the SIE-first gate
lives), the SIE chip opens the upload step, new business lands on Hem
with step one checked off.

mode='add' keeps the plain "Öppna Accounted" button: the concept's own
guard, and it avoids writing the path onto the previous company if
setActiveCompany silently failed. Routing lives in a pure helper with
tests; anonymous onboarding_branch_chosen funnel event follows the
guarded capture pattern.

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

* fix(onboarding): review triage: single-choice latch, preselect reset

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

* fix: restore package-lock.json to main (worktree npm install mutated it)

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-08 15:27:17 +02:00
committed by GitHub
co-authored by Claude Fable 5 Jakob Wennberg
parent 44cff5e5e4
commit c7dd78b0a3
9 changed files with 252 additions and 9 deletions
@@ -1,6 +1,6 @@
'use client'
import { useState, useCallback, useEffect } from 'react'
import { useState, useCallback, useEffect, useRef } from 'react'
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Progress } from '@/components/ui/progress'
@@ -1799,7 +1799,13 @@ function EntityResultRow({
// ── Main wizard ─────────────────────────────────────────────────
export default function ArcimMigrationWorkspace(_props: WorkspaceComponentProps) {
export default function ArcimMigrationWorkspace({
initialProvider,
}: WorkspaceComponentProps & {
/** Deep-linked old system (onboarding branch question): jump straight to
* its connect step instead of showing the provider list. */
initialProvider?: string
}) {
const { toast } = useToast()
const [step, setStep] = useState<WizardStep>('provider')
@@ -2129,6 +2135,22 @@ export default function ArcimMigrationWorkspace(_props: WorkspaceComponentProps)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
// Deep-linked provider preselect (onboarding branch question). Only when
// this mount is not an OAuth return (that flow owns the wizard state), and
// only for providers whose SIE comes via API: visma/bokio must first see
// the provider list with its "SIE krävs först" gate, which depends on
// async connection status.
const preselectedRef = useRef(false)
useEffect(() => {
if (preselectedRef.current || !initialProvider) return
if (new URL(window.location.href).searchParams.get('migration')) return
const provider = ARCIM_PROVIDERS.find((p) => p.id === initialProvider)
if (!provider || COMING_SOON_PROVIDERS.has(provider.id) || !provider.sieViaApi) return
preselectedRef.current = true
void handleSelectProvider(provider.id)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [initialProvider])
// Listen for postMessage from OAuth popup
useEffect(() => {
function handleMessage(event: MessageEvent) {
@@ -11,6 +11,13 @@ import { parseStartMonthDay } from '@/lib/company/first-year-defaults'
import { fetchCompanyLookup } from '@/lib/company-lookup/fetch-company-lookup'
import { normalizeOrgNumber } from '@/lib/company-lookup/normalize-org-number'
import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions'
import posthog from 'posthog-js'
import { isAnalyticsEnabled } from '@/lib/analytics/enabled'
import {
BRANCH_PROVIDERS,
branchDestination,
type BranchChoice,
} from '@/lib/onboarding-journey/branch'
import {
initJourney,
journeyReducer,
@@ -55,6 +62,20 @@ function logError(message: string, extra?: Record<string, unknown>) {
}).catch(() => {})
}
/**
* Branch-question funnel event. Anonymous by design: AnalyticsIdentify only
* mounts in the dashboard layout, so this measures choice distribution, not
* people. Guarded + swallowed like every product capture.
*/
function captureBranch(choice: BranchChoice) {
if (!isAnalyticsEnabled()) return
try {
posthog.capture('onboarding_branch_chosen', { choice })
} catch {
// Telemetry must never affect the journey.
}
}
interface OnboardingJourneyProps {
teamId: string
mode?: 'first' | 'add'
@@ -87,6 +108,8 @@ export default function OnboardingJourney({
)
const bandRef = useRef<HTMLDivElement | null>(null)
// Latches after the first done-screen branch choice (see onBranch below).
const branchChosenRef = useRef(false)
const [orgInput, setOrgInput] = useState(initialOrgNumber ?? '')
const [orgShake, setOrgShake] = useState(false)
const [thinking, setThinking] = useState(false)
@@ -634,10 +657,32 @@ export default function OnboardingJourney({
<DoneStep
t={t}
state={state}
mode={mode}
fyAnswer={fyAnswer}
momsAnswer={momsAnswer}
methodAnswer={methodAnswer}
onOpen={() => router.push('/')}
onBranch={(choice) => {
// One choice only: rapid clicks on different chips must not
// race two PATCHes (last-write-wins could persist the wrong
// path after navigation).
if (branchChosenRef.current) return
branchChosenRef.current = true
const dest = branchDestination(choice)
if (dest.path) {
// Fire-and-forget: the checklist path is a nicety, routing is
// the point. A lost PATCH just leaves the Hem checklist
// unpathed; it must never block or delay the navigation.
fetch('/api/onboarding/state', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path: dest.path }),
keepalive: true,
}).catch(() => logError('branch path persist failed', { choice }))
}
captureBranch(choice)
router.push(dest.href)
}}
/>
)
}
@@ -957,17 +1002,21 @@ function FyEndStep({
function DoneStep({
t,
state,
mode,
fyAnswer,
momsAnswer,
methodAnswer,
onOpen,
onBranch,
}: {
t: TFn
state: JourneyState
mode: 'first' | 'add'
fyAnswer: string | null
momsAnswer: string | null
methodAnswer: string | null
onOpen: () => void
onBranch: (choice: BranchChoice) => void
}) {
const s = state.settings
const shortName = (s.company_name ?? '').split(' ')[0] || ''
@@ -1017,15 +1066,64 @@ function DoneStep({
))}
</div>
) : null}
<div className="jny-qactions">
<button type="button" className="jny-btn" onClick={onOpen}>
{t('journey_open_app')}
</button>
</div>
{mode === 'first' ? (
<Reveal delay={notes.length > 0 ? 1400 + notes.length * 260 : 1200}>
<div className="jny-done-branch">
<h2 className="jny-done-q">
<InkText text={t('journey_done_source_title')} />
</h2>
<p className="jny-done-sub">{t('journey_done_source_sub')}</p>
<div className="jny-chips">
{BRANCH_PROVIDERS.map((p) => (
<button
key={p.id}
type="button"
className="jny-pick"
onClick={() => onBranch(p.id)}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={p.logo} alt="" />
{p.name}
</button>
))}
<button type="button" className="jny-pick" onClick={() => onBranch('sie')}>
{t('journey_done_source_sie')}
</button>
<button type="button" className="jny-pick" onClick={() => onBranch('fresh')}>
{t('journey_done_source_fresh')}
</button>
</div>
<div className="jny-qactions">
<button type="button" className="jny-btn-quiet" onClick={() => onBranch('skip')}>
{t('journey_done_source_skip')}
</button>
</div>
</div>
</Reveal>
) : (
<div className="jny-qactions">
<button type="button" className="jny-btn" onClick={onOpen}>
{t('journey_open_app')}
</button>
</div>
)}
</div>
)
}
/** Delayed mount so the branch question enters (with the standard .jny-qstep
* rise) only after the profile card and notes have finished settling. */
function Reveal({ delay, children }: { delay: number; children: React.ReactNode }) {
const [on, setOn] = useState(false)
useEffect(() => {
const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches
const id = window.setTimeout(() => setOn(true), reduced ? 0 : delay)
return () => window.clearTimeout(id)
}, [delay])
if (!on) return null
return <div className="jny-qstep">{children}</div>
}
function CardRow({ label, value, delay }: { label: string; value: string; delay: number }) {
const [on, setOn] = useState(false)
useEffect(() => {
@@ -269,3 +269,12 @@
@media (prefers-reduced-motion: reduce) {
.jny *, .jny *::before, .jny *::after { animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; }
}
/* ── done-screen branch question ─────────────────────────────────── */
.jny-done-branch { margin-top: 34px; }
.jny-done-q { font-family: var(--font-display); font-size: 20px; font-weight: 400; margin: 0 0 4px; letter-spacing: -0.01em; }
.jny-done-sub { font-size: 12.5px; color: hsl(var(--muted-foreground)); margin: 0 0 18px; }
.jny-pick img { width: 16px; height: 16px; object-fit: contain; border-radius: 4px; background: #fff; vertical-align: -3px; margin-right: 8px; }
@media (max-height: 780px) {
.jny-done-branch { margin-top: 22px; }
}