From c7dd78b0a3b69f667d05aa5b3fb4bc8576555d1f Mon Sep 17 00:00:00 2001 From: Jakob Wennberg Date: Sat, 8 Aug 2026 15:27:17 +0200 Subject: [PATCH] feat(onboarding): branch question on the journey done screen (#1468) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 * fix(onboarding): review triage: single-choice latch, preselect reset Co-Authored-By: Claude Fable 5 * fix: restore package-lock.json to main (worktree npm install mutated it) Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 --- DECISIONS.md | 1 + app/(dashboard)/import/page.tsx | 23 +++- .../general/ArcimMigrationWorkspace.tsx | 26 ++++- .../onboarding/journey/OnboardingJourney.tsx | 108 +++++++++++++++++- components/onboarding/journey/journey.css | 9 ++ .../__tests__/branch.test.ts | 37 ++++++ lib/onboarding-journey/branch.ts | 47 ++++++++ messages/en.json | 5 + messages/sv.json | 5 + 9 files changed, 252 insertions(+), 9 deletions(-) create mode 100644 lib/onboarding-journey/__tests__/branch.test.ts create mode 100644 lib/onboarding-journey/branch.ts diff --git a/DECISIONS.md b/DECISIONS.md index 88aab9ec..d49f1fd8 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -835,4 +835,5 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-08-08] Per-voucher RC basis gaps (findRcBasisGaps) downgrade from filing-blocking ERROR to WARNING only under per-rate evidence: the 44xx/45xx basis accounts grouped by momssats must match ruta30/0.25, ruta31/0.12, ruta32/0.06 two-sided within 0.5 kr, all rate boxes non-negative, and no RC_OUTPUT_MISSING present. A first cross-rate-sum predicate was refuted by /skeptic (wrong-rate fiktiv moms reached parity and unblocked a 7 800 kr under-declaration; a negative rate box made the predicate vacuous), so the certificate is per-rate, which rutor alone cannot express (rutor 20-24 are partitioned by purchase type, not rate); evidence therefore flows from the account totals. Why downgrade at all: a moms-only rattelseverifikat carries fiktiv moms whose basbelopp lives in another (often reversed) verifikat, and no voucher arrangement satisfies both the per-voucher scan and the aggregate identity in that state, so the ERROR was an unfixable dead end (Orto Engineering 3DJake case 2026-08; support vouchers A169/A175/A177 joined the blocklist they were meant to clear). Data side repaired separately with voucher A177 restoring bank parity and the basis/moms identity exactly. [2026-08-08] Fenced-JSON fix uses brace-slice, not fence-regex: also rescues preamble/postamble prose around the object, and degrades to the existing empty-result path when no braces exist. [2026-08-08] extractJsonObject upgraded from brace-slice to depth-aware balanced scan after PR 1460 review: prose containing braces around the JSON no longer poisons the slice; first parseable candidate wins. +[2026-08-08] Journey branch question (PR 2 of the activation concept) renders only in mode='first' and persists initial_setup_path as a fire-and-forget PATCH: in mode='add' a silently-failed setActiveCompany (deliberately non-fatal in createCompanyFromOnboarding) would make the PATCH land on the PREVIOUS company's settings, and experienced multi-company users get the Hem checklist anyway; navigation never blocks on the PATCH because the checklist path is a nicety, not a prerequisite. Provider preselect at /import?mode=migration&provider=X auto-advances only for sieViaApi providers (fortnox/bjornlunden/briox): visma/bokio must land on the provider list where the "SIE krävs först" gate renders with its async connection status. [2026-08-08] SIE export always emits #FORMAT PC8 even when bytes are UTF-8: the record is compulsory in the spec and strict importers (Visma Spiris) reject files without it, while real encoding is detected from bytes (Fortnox ships the same shape). Default bytes stay UTF-8; encoding=cp437 remains opt-in. diff --git a/app/(dashboard)/import/page.tsx b/app/(dashboard)/import/page.tsx index d5dc579b..bb4b850b 100644 --- a/app/(dashboard)/import/page.tsx +++ b/app/(dashboard)/import/page.tsx @@ -1968,6 +1968,7 @@ type ImportMode = null | 'psd2' | 'stripe' | 'woocommerce' | 'bank' | 'sie' | 'c export default function ImportPage() { const { isSandbox } = useCompany() const [mode, setMode] = useState(null) + const [initialProvider, setInitialProvider] = useState(null) const [view, setView] = useState<'import' | 'export'>('import') const [sieDialogOpen, setSieDialogOpen] = useState(false) const [cloudOpen, setCloudOpen] = useState(false) @@ -2003,6 +2004,12 @@ export default function ImportPage() { if (modeParam && allowedModes.includes(modeParam)) { setMode(modeParam as ImportMode) } + // Deep link from the onboarding branch question: preselect the old + // system so the wizard can jump straight to its connect step. Cleared + // for every other mode so a stale preselect can't survive re-entry. + setInitialProvider( + modeParam === 'migration' && !isSandbox ? searchParams.get('provider') : null + ) } const viewParam = searchParams.get('view') if (viewParam === 'export' || viewParam === 'import') { @@ -2236,7 +2243,17 @@ export default function ImportPage() { )} {mode !== null && ( - @@ -2294,7 +2311,9 @@ export default function ImportPage() { {mode === 'bank' && } {mode === 'sie' && } {mode === 'csv_data' && } - {mode === 'migration' && } + {mode === 'migration' && ( + + )} ) } diff --git a/components/extensions/general/ArcimMigrationWorkspace.tsx b/components/extensions/general/ArcimMigrationWorkspace.tsx index 122bdf62..33da3e9f 100644 --- a/components/extensions/general/ArcimMigrationWorkspace.tsx +++ b/components/extensions/general/ArcimMigrationWorkspace.tsx @@ -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('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) { diff --git a/components/onboarding/journey/OnboardingJourney.tsx b/components/onboarding/journey/OnboardingJourney.tsx index 416040a8..976959e5 100644 --- a/components/onboarding/journey/OnboardingJourney.tsx +++ b/components/onboarding/journey/OnboardingJourney.tsx @@ -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) { }).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(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({ 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({ ))} ) : null} -
- -
+ {mode === 'first' ? ( + 0 ? 1400 + notes.length * 260 : 1200}> +
+

+ +

+

{t('journey_done_source_sub')}

+
+ {BRANCH_PROVIDERS.map((p) => ( + + ))} + + +
+
+ +
+
+
+ ) : ( +
+ +
+ )} ) } +/** 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
{children}
+} + function CardRow({ label, value, delay }: { label: string; value: string; delay: number }) { const [on, setOn] = useState(false) useEffect(() => { diff --git a/components/onboarding/journey/journey.css b/components/onboarding/journey/journey.css index de441a19..185f589e 100644 --- a/components/onboarding/journey/journey.css +++ b/components/onboarding/journey/journey.css @@ -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; } +} diff --git a/lib/onboarding-journey/__tests__/branch.test.ts b/lib/onboarding-journey/__tests__/branch.test.ts new file mode 100644 index 00000000..2618352f --- /dev/null +++ b/lib/onboarding-journey/__tests__/branch.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest' +import { BRANCH_PROVIDERS, branchDestination, type BranchChoice } from '../branch' + +describe('branchDestination', () => { + it.each(['fortnox', 'visma', 'bokio', 'bjornlunden', 'briox'] as const)( + 'routes %s into the migration wizard with the provider preselected', + (provider) => { + expect(branchDestination(provider)).toEqual({ + path: 'migration', + href: `/import?mode=migration&provider=${provider}`, + }) + } + ) + + it('routes the SIE file straight to the upload step', () => { + expect(branchDestination('sie')).toEqual({ path: 'migration', href: '/import?mode=sie' }) + }) + + it('marks a new business as fresh and lands on Hem', () => { + expect(branchDestination('fresh')).toEqual({ path: 'fresh', href: '/' }) + }) + + it('persists nothing on a pure skip', () => { + expect(branchDestination('skip')).toEqual({ path: null, href: '/' }) + }) + + it('keeps provider chips aligned with the routable choices', () => { + const choices = new Set(BRANCH_PROVIDERS.map((p) => p.id)) + for (const id of choices) { + expect(branchDestination(id).path).toBe('migration') + } + // Every provider entry carries a real logo path under /logos/. + for (const p of BRANCH_PROVIDERS) { + expect(p.logo.startsWith('/logos/')).toBe(true) + } + }) +}) diff --git a/lib/onboarding-journey/branch.ts b/lib/onboarding-journey/branch.ts new file mode 100644 index 00000000..a4ea9c4b --- /dev/null +++ b/lib/onboarding-journey/branch.ts @@ -0,0 +1,47 @@ +import type { InitialSetupPath } from '@/types' + +/** + * The done-screen branch question ("Var fanns bokföringen innan?"): the one + * routing decision made at peak motivation, right after the company exists. + * Providers deep-link into the migration wizard; the SIE file goes straight + * to the upload step; a new business skips the import entirely and starts + * on Hem where the checklist points at the bank next. + */ +export type BranchChoice = + | 'fortnox' + | 'visma' + | 'bokio' + | 'bjornlunden' + | 'briox' + | 'sie' + | 'fresh' + | 'skip' + +export const BRANCH_PROVIDERS: { id: BranchChoice; name: string; logo: string }[] = [ + { id: 'fortnox', name: 'Fortnox', logo: '/logos/fortnox.svg' }, + { id: 'visma', name: 'Visma', logo: '/logos/visma.jpeg' }, + { id: 'bokio', name: 'Bokio', logo: '/logos/bokio.png' }, + { id: 'bjornlunden', name: 'Björn Lundén', logo: '/logos/bjornlunden.png' }, + { id: 'briox', name: 'Briox', logo: '/logos/Briox_logo.png' }, +] + +export function branchDestination(choice: BranchChoice): { + /** initial_setup_path to persist; null = persist nothing (pure skip). */ + path: InitialSetupPath | null + href: string +} { + switch (choice) { + case 'fortnox': + case 'visma': + case 'bokio': + case 'bjornlunden': + case 'briox': + return { path: 'migration', href: `/import?mode=migration&provider=${choice}` } + case 'sie': + return { path: 'migration', href: '/import?mode=sie' } + case 'fresh': + return { path: 'fresh', href: '/' } + case 'skip': + return { path: null, href: '/' } + } +} diff --git a/messages/en.json b/messages/en.json index 3b16ed75..ab5f6e2b 100644 --- a/messages/en.json +++ b/messages/en.json @@ -1488,6 +1488,11 @@ "journey_note_vat_watch": "We watch the SEK 120,000 threshold and let you know in good time.", "journey_note_ceased": "The company is marked as deregistered at Bolagsverket.", "journey_open_app": "Open Accounted", + "journey_done_source_title": "Where were the books before?", + "journey_done_source_sub": "Your history comes along, down to the last öre.", + "journey_done_source_sie": "SIE file", + "journey_done_source_fresh": "This is a new business", + "journey_done_source_skip": "I'll look around first", "journey_ans_calendar": "Calendar year", "journey_ans_broken": "Broken: {from} to {to}", "journey_ans_first": "First year: {from} to {to}", diff --git a/messages/sv.json b/messages/sv.json index e6058cc1..6057f4cd 100644 --- a/messages/sv.json +++ b/messages/sv.json @@ -1488,6 +1488,11 @@ "journey_note_vat_watch": "Vi bevakar 120 000-gränsen och säger till i god tid.", "journey_note_ceased": "Företaget är markerat som avregistrerat hos Bolagsverket.", "journey_open_app": "Öppna Accounted", + "journey_done_source_title": "Var fanns bokföringen innan?", + "journey_done_source_sub": "Historiken följer med hit, ner till sista öret.", + "journey_done_source_sie": "SIE-fil", + "journey_done_source_fresh": "Det här är ny verksamhet", + "journey_done_source_skip": "Jag ser mig omkring först", "journey_ans_calendar": "Kalenderår", "journey_ans_broken": "Brutet: {from} till {to}", "journey_ans_first": "Första året: {from} till {to}",