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:
Jakob Wennberg
2026-08-14 12:25:23 +02:00
committed by GitHub
co-authored by Jakob Wennberg Claude Fable 5
parent fb3feaea12
commit 0938646693
7 changed files with 150 additions and 63 deletions
@@ -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' })}>
&lsaquo; {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)
+24 -6
View File
@@ -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; }
}