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
@@ -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; }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user