feat(onboarding): the branch question becomes its own journey step (#1615)
Founder feedback from a real signup: the done screen stacked the welcome, the 8-row company profile card AND the branch question, pushing the question below the fold, and the tiny favicons-in-ellipses provider chips looked bad. The done screen now ends in a revealed Fortsatt action; a new 'source' step at the existing KLART station (same station grammar as momsyn/moms under MOMSEN) shows only "Var fanns bokforingen innan?" with redesigned provider tiles: a 2-column grid of generously sized choices, each with the real logo on a small white bordered mark (the LogoMark grammar from NewUserChecklist), SIE-fil and new-business as equal-weight text tiles, flat hover, no lift. Everything fits one viewport. Behavior preserved exactly: mode='first' only (reducer-guarded DONE_CONTINUE), the single-choice latch, fire-and-forget keepalive PATCH /api/onboarding/state, captureBranch analytics, branchDestination routing, and the quiet skip escape. 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:
co-authored by
Jakob Wennberg
Claude Fable 5
parent
fb3feaea12
commit
0938646693
@@ -984,5 +984,6 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
|
||||
[2026-08-13] Radius ladder locked (convention 16): 4 tiers by role (pill toolbar controls / rounded-xl overlays / rounded-lg surfaces / rounded-sm leaves); rounded-md, bare rounded, rounded-2xl and rounded-[Npx] retired app-wide, hard-failed by check:guards off-ladder-radius. Before: 7 radii in circulation with no rule; one toolbar row on /transactions mixed 4 shape languages.
|
||||
[2026-08-13] Toolbar shape language: pills won over "inputs stay rectangles". Search in a page toolbar is a pill (ToolbarSearch, h-8) matching chips/pickers/buttons beside it; the same search inside a dialog or form keeps rounded-lg Input. Rationale: convention 8 already made pickers pill chips and convention 3 made buttons pills, so the rectangle search/segmented were the odd ones out; one row = one shape reads as trust.
|
||||
[2026-08-13] Dialogs promoted 8px -> 12px (rounded-xl): SettingsModal, slide-over and CommandPalette were already 12px, so dialog.tsx was the overlay-tier outlier, not the rule.
|
||||
[2026-08-14] The onboarding branch question ("Var fanns bokföringen innan?") became its own journey step at the existing KLART station (done -> source, same station grammar as momsyn/moms under MOMSEN) instead of a sixth rail station: a 6-point rail crowds the 680px band's 150px label slots, "BOKFÖRINGEN INNAN" would sit next to the existing "BOKFÖRINGEN" station, and mode='add' (which never sees the branch question) would need an asymmetric rail. The done screen ends in a revealed Fortsätt that dispatches DONE_CONTINUE (mode='first' only, reducer-guarded).
|
||||
[2026-08-14] Migration /preview fetches ALL allowed fiscal years (dropped latestOnly): the connect step's "Hittade X konton och Y verifikationer" renders from /preview's sieStats, not /sie-data's generateImportPreview, so fixing only /sie-data would have left the founder-reported "0 verifikationer" (actual: 4153) on screen. Costs one SIE export per extra year at connect time, the same work /sie-data repeats right after; honest counts won over latency.
|
||||
[2026-08-14] /sie-data validation stays newest-file-only (not per-file, not on the merged parse): preserves exactly which datasets are accepted today, and validateSIEFile assumes single-file invariants (balance yearIndexes relative to ONE current year) that mergeParsedSIEFiles deliberately does not preserve. Older files' problems still surface per-file at import time.
|
||||
|
||||
@@ -180,12 +180,36 @@ export default function OnboardingJourney({
|
||||
if (initialOrgNumber) submitOrg(initialOrgNumber)
|
||||
}, [initialOrgNumber, submitOrg])
|
||||
|
||||
// One choice only: rapid clicks on different chips must not race two
|
||||
// PATCHes (last-write-wins could persist the wrong path after navigation).
|
||||
const onBranch = useCallback(
|
||||
(choice: BranchChoice) => {
|
||||
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)
|
||||
},
|
||||
[router],
|
||||
)
|
||||
|
||||
// A short thinking beat between questions.
|
||||
const prevStep = useRef(state.step)
|
||||
useEffect(() => {
|
||||
if (prevStep.current === state.step) return
|
||||
prevStep.current = state.step
|
||||
if (state.step === 'done' || state.submitting) return
|
||||
if (state.step === 'done' || state.step === 'source' || state.submitting) return
|
||||
setThinking(true)
|
||||
const timer = window.setTimeout(() => setThinking(false), 420)
|
||||
return () => window.clearTimeout(timer)
|
||||
@@ -257,7 +281,7 @@ export default function OnboardingJourney({
|
||||
|
||||
/* ── derived display ──────────────────────────────────────────── */
|
||||
|
||||
const orbState: OrbState = state.step === 'done'
|
||||
const orbState: OrbState = state.step === 'done' || state.step === 'source'
|
||||
? 'check'
|
||||
: state.submitting
|
||||
? narration === null
|
||||
@@ -662,29 +686,12 @@ export default function OnboardingJourney({
|
||||
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)
|
||||
}}
|
||||
onContinue={() => dispatch({ type: 'DONE_CONTINUE' })}
|
||||
/>
|
||||
)
|
||||
|
||||
case 'source':
|
||||
return <SourceStep t={t} onBranch={onBranch} />
|
||||
}
|
||||
}
|
||||
|
||||
@@ -762,7 +769,10 @@ export default function OnboardingJourney({
|
||||
|
||||
<div className="jny-balance" aria-hidden="true" />
|
||||
<div className="jny-backrow">
|
||||
{state.history.length > 0 && state.step !== 'done' && !state.submitting ? (
|
||||
{state.history.length > 0 &&
|
||||
state.step !== 'done' &&
|
||||
state.step !== 'source' &&
|
||||
!state.submitting ? (
|
||||
<button type="button" className="jny-btn-quiet" onClick={() => dispatch({ type: 'BACK' })}>
|
||||
‹ {t('back')}
|
||||
</button>
|
||||
@@ -1007,7 +1017,7 @@ function DoneStep({
|
||||
momsAnswer,
|
||||
methodAnswer,
|
||||
onOpen,
|
||||
onBranch,
|
||||
onContinue,
|
||||
}: {
|
||||
t: TFn
|
||||
state: JourneyState
|
||||
@@ -1016,7 +1026,7 @@ function DoneStep({
|
||||
momsAnswer: string | null
|
||||
methodAnswer: string | null
|
||||
onOpen: () => void
|
||||
onBranch: (choice: BranchChoice) => void
|
||||
onContinue: () => void
|
||||
}) {
|
||||
const s = state.settings
|
||||
const shortName = (s.company_name ?? '').split(' ')[0] || ''
|
||||
@@ -1068,36 +1078,10 @@ function DoneStep({
|
||||
) : null}
|
||||
{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 className="jny-qactions">
|
||||
<button type="button" className="jny-btn" onClick={onContinue}>
|
||||
{t('journey_done_continue')}
|
||||
</button>
|
||||
</div>
|
||||
</Reveal>
|
||||
) : (
|
||||
@@ -1111,7 +1095,43 @@ function DoneStep({
|
||||
)
|
||||
}
|
||||
|
||||
/** Delayed mount so the branch question enters (with the standard .jny-qstep
|
||||
/**
|
||||
* The branch question as its own step, sharing the Klart station with the
|
||||
* welcome screen (same station grammar as momsyn/moms under Momsen).
|
||||
* Providers and the SIE file share one grid of generously sized tiles; the
|
||||
* provider tiles carry the real logo on a small white mark (the LogoMark
|
||||
* grammar from NewUserChecklist), the text answers stay equal-weight tiles.
|
||||
*/
|
||||
function SourceStep({ t, onBranch }: { t: TFn; onBranch: (choice: BranchChoice) => void }) {
|
||||
return (
|
||||
<Question title={t('journey_done_source_title')} sub={t('journey_done_source_sub')}>
|
||||
<div className="jny-srcgrid">
|
||||
{BRANCH_PROVIDERS.map((p) => (
|
||||
<button key={p.id} type="button" className="jny-srcpick" onClick={() => onBranch(p.id)}>
|
||||
<span className="jny-srcmark">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src={p.logo} alt="" />
|
||||
</span>
|
||||
{p.name}
|
||||
</button>
|
||||
))}
|
||||
<button type="button" className="jny-srcpick is-text" onClick={() => onBranch('sie')}>
|
||||
{t('journey_done_source_sie')}
|
||||
</button>
|
||||
<button type="button" className="jny-srcpick is-text is-span" 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>
|
||||
</Question>
|
||||
)
|
||||
}
|
||||
|
||||
/** Delayed mount so the continue action 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)
|
||||
|
||||
@@ -270,11 +270,29 @@
|
||||
.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; }
|
||||
/* ── source step: where were the books before ────────────────────── */
|
||||
/* One grid, equal-weight tiles. Providers carry a real logo on a small
|
||||
white mark (the LogoMark grammar); SIE and new-business are text tiles
|
||||
in the same shape. Hover is a flat background shift, no lift. */
|
||||
.jny-srcgrid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; max-width: 440px; margin: 0 auto; }
|
||||
.jny-srcpick {
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
min-height: 52px; padding: 10px 16px;
|
||||
border: 1px solid hsl(var(--border)); border-radius: 8px;
|
||||
background: hsl(var(--background));
|
||||
font-family: inherit; font-size: 13.5px; color: inherit; text-align: left;
|
||||
cursor: pointer;
|
||||
transition: background-color 150ms var(--ease-out);
|
||||
}
|
||||
.jny-srcpick:hover { background: hsl(var(--secondary) / 0.6); }
|
||||
.jny-srcpick.is-text { justify-content: center; text-align: center; }
|
||||
.jny-srcpick.is-span { grid-column: 1 / -1; }
|
||||
.jny-srcmark {
|
||||
flex: none; display: flex; align-items: center; justify-content: center;
|
||||
width: 28px; height: 28px; overflow: hidden;
|
||||
border: 1px solid hsl(var(--border)); border-radius: 4px; background: #fff;
|
||||
}
|
||||
.jny-srcmark img { width: 18px; height: 18px; object-fit: contain; }
|
||||
@media (max-height: 780px) {
|
||||
.jny-done-branch { margin-top: 22px; }
|
||||
.jny-srcpick { min-height: 46px; padding: 8px 14px; }
|
||||
}
|
||||
|
||||
@@ -461,6 +461,41 @@ describe('journeyReducer: server errors', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('journeyReducer: branch question step (source)', () => {
|
||||
function atDone(mode: 'first' | 'add' = 'first'): JourneyState {
|
||||
return run(
|
||||
initJourney({ mode }),
|
||||
{ type: 'ORG_SUBMITTED', orgNumber: '556677-8899' },
|
||||
{ type: 'LOOKUP_RESULT', outcome: { status: 'found', result: lookup() } },
|
||||
{ type: 'FY_CALENDAR_CONFIRMED' },
|
||||
{ type: 'MOMS_PERIOD_PICKED', period: 'quarterly' },
|
||||
{ type: 'METHOD_PICKED', method: 'accrual' },
|
||||
{ type: 'SUBMIT_SUCCEEDED' },
|
||||
)
|
||||
}
|
||||
|
||||
it('continue moves the done screen to the source step, still on the Klart station', () => {
|
||||
const s = journeyReducer(atDone(), { type: 'DONE_CONTINUE' })
|
||||
expect(s.step).toBe('source')
|
||||
expect(stationOfStep(s.step)).toBe(4)
|
||||
})
|
||||
|
||||
it("mode='add' never reaches the source step", () => {
|
||||
const s = atDone('add')
|
||||
expect(journeyReducer(s, { type: 'DONE_CONTINUE' })).toBe(s)
|
||||
})
|
||||
|
||||
it('is ignored anywhere but the done screen', () => {
|
||||
const s = manualAbAtFy()
|
||||
expect(journeyReducer(s, { type: 'DONE_CONTINUE' })).toBe(s)
|
||||
})
|
||||
|
||||
it('Back from the source step restores the done screen', () => {
|
||||
const s = run(atDone(), { type: 'DONE_CONTINUE' }, { type: 'BACK' })
|
||||
expect(s.step).toBe('done')
|
||||
})
|
||||
})
|
||||
|
||||
describe('journeyReducer: robustness', () => {
|
||||
it('ignores a LOOKUP_RESULT when no lookup is pending', () => {
|
||||
const s = initJourney()
|
||||
|
||||
@@ -40,6 +40,7 @@ export type JourneyStep =
|
||||
| 'moms'
|
||||
| 'method'
|
||||
| 'done'
|
||||
| 'source'
|
||||
|
||||
export type JourneyStation = 0 | 1 | 2 | 3 | 4
|
||||
|
||||
@@ -59,6 +60,7 @@ const STATION_OF: Record<JourneyStep, JourneyStation> = {
|
||||
moms: 2,
|
||||
method: 3,
|
||||
done: 4,
|
||||
source: 4,
|
||||
}
|
||||
|
||||
export function stationOfStep(step: JourneyStep): JourneyStation {
|
||||
@@ -128,6 +130,7 @@ export type JourneyAction =
|
||||
| { type: 'METHOD_PICKED'; method: 'accrual' | 'cash' }
|
||||
| { type: 'SUBMIT_SUCCEEDED' }
|
||||
| { type: 'SUBMIT_FAILED'; code: 'org_number_invalid' | 'period_invalid' | 'generic' }
|
||||
| { type: 'DONE_CONTINUE' }
|
||||
| { type: 'BACK' }
|
||||
| { type: 'STATION_JUMP'; station: 0 | 1 | 2 | 3 }
|
||||
|
||||
@@ -475,6 +478,14 @@ export function journeyReducer(state: JourneyState, action: JourneyAction): Jour
|
||||
case 'SUBMIT_SUCCEEDED':
|
||||
return go(stay(state, { submitting: false }), 'done')
|
||||
|
||||
case 'DONE_CONTINUE': {
|
||||
// Welcome screen → the branch question ("Var fanns bokföringen
|
||||
// innan?") as its own step. First-company flow only: mode='add'
|
||||
// ends on the done screen with "Öppna appen".
|
||||
if (state.step !== 'done' || state.mode !== 'first') return state
|
||||
return go(state, 'source')
|
||||
}
|
||||
|
||||
case 'SUBMIT_FAILED': {
|
||||
const cleared = stay(state, { submitting: false })
|
||||
if (action.code === 'org_number_invalid') {
|
||||
|
||||
@@ -1623,6 +1623,7 @@
|
||||
"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_continue": "Continue",
|
||||
"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",
|
||||
|
||||
@@ -1623,6 +1623,7 @@
|
||||
"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_continue": "Fortsätt",
|
||||
"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",
|
||||
|
||||
Reference in New Issue
Block a user