feat(onboarding): journey PR B — reducer, orb, track, question primitives (behind /sandbox demo) (#1145)

* refactor(onboarding): extract first-year defaults + shared TIC lookup client (journey PR A)

First of four PRs replacing the onboarding wizard with the journey flow
(dev_docs/onboarding_migration_plan.md, local). No UI change.

- Move deriveFirstYearDefaults + parseStartMonthDay out of
  WelcomeOnboarding into lib/company/first-year-defaults.ts and unit-test
  them (11-vs-13-months boundary, UTC month seeding, malformed input).
- Add the missing computeFiscalPeriod unit tests (calendar year, brutet
  ar, first year short/extended, EF calendar-year rule, period names,
  BFL 3 kap. 6-18 month window errors).
- New shared fetchCompanyLookup() client: the single client path to the
  Lens-backed /lookup, typed outcomes (found / not_found / disabled /
  error / aborted), never throws. Fixes the 403/404 conflation: the
  dispatcher's 404 ("Extension not found") and feature-flag 503
  (EXTENSION_DISABLED) now degrade silently instead of rendering as
  "company not found"; only the TIC handler's own 404 does.
- Step2CompanyDetails consumes the helper; identical UX otherwise.

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

* feat(onboarding): journey state machine reducer with full branch coverage (journey PR B, 1/3)

Pure reducer for the journey onboarding: owns every transition and every
CompanySettings write; the component layer only renders steps, runs the
single TIC lookup, and calls the server action.

Encodes the plan's invariants: entry-snapshot history (Back rolls answers
AND stations), lookupRan gates fact-vs-question per field (BankID prefill
without lookup degrades to questions), vat_registered is never defaulted
without lookup data or an explicit answer, entity change wipes downstream,
org_number_invalid bounces to the Företaget station, station jumps rewind
to a station's first step.

34 unit tests: AB/EF found, not-found manual, ceased, BankID prefill
(found + degraded + disabled), first year, brutet år, moms nej, Back from
every step, station jumps, server-error bounces.

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

* feat(onboarding): journey visual primitives + sandbox gallery (journey PR B, 2/3)

Ports the founder-approved concept (artifact c82c9358) to React:

- JourneyOrb: 320-particle canvas sphere with comet travel, check morph
  and the monogram finale (glyph sampled live from --font-display). Own
  component per plan, NOT thinking-orbs. rAF pauses on document.hidden;
  reduced motion renders static frames.
- JourneyTrack: five stations with inked answers; completed stations are
  keyboard-accessible jump-back buttons; answers mirrored to an aria-live
  region.
- Question primitives: Question (ink title + "?" popover, Esc closes),
  ChipRow (fly-to-orb ghost), YearBand (springy fiscal-year preview),
  JourneyDatePicker (year -> month by name -> day), AddressFields
  (Enter-chained, skippable).
- journey.css: concept stylesheet namespaced under .jny on app tokens,
  incl. the no-scroll composition (100dvh + optical-centering balance
  spacer) and the dawn layer.
- /sandbox/journey: internal primitive gallery (auth-free sandbox path),
  demo data only: this page makes ZERO TIC calls.

i18n note: primitives are copy-agnostic (strings via props); the real
flow's sv/en keys land with their consumer in PR C.

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

* docs: log journey reducer location decision

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

* fix(onboarding): annotate ENTITY_PICKED settings as Partial<CompanySettings>

The wipeDownstream return narrows against the inferred initializer type;
tsc strict rejects the reassignment without the explicit annotation.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-07-24 13:32:45 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent f9ef8913ae
commit b771c1f923
13 changed files with 2273 additions and 0 deletions
+205
View File
@@ -0,0 +1,205 @@
'use client'
/**
* Internal demo route for the journey onboarding primitives (PR B).
* Auth-free via the /sandbox middleware exemption; view logged out.
*
* A gallery, not the product flow: it exercises JourneyOrb (all states,
* travel, monogram morph), JourneyTrack (answers, jump-back), Question +
* ChipRow (fly-to-orb ghost), YearBand, JourneyDatePicker and
* AddressFields, with demo data only. NO TIC calls are made from this
* page: the lookup budget is untouched. Swedish-only by design (internal
* surface); the real flow (PR C) is fully i18n'd.
*/
import { useRef, useState } from 'react'
import JourneyOrb, { type OrbState } from '@/components/onboarding/journey/JourneyOrb'
import JourneyTrack from '@/components/onboarding/journey/JourneyTrack'
import Question from '@/components/onboarding/journey/Question'
import ChipRow from '@/components/onboarding/journey/ChipRow'
import YearBand from '@/components/onboarding/journey/YearBand'
import JourneyDatePicker from '@/components/onboarding/journey/JourneyDatePicker'
import AddressFields from '@/components/onboarding/journey/AddressFields'
import '@/components/onboarding/journey/journey.css'
const ORB_STATES: OrbState[] = ['listening', 'searching', 'thinking', 'working', 'shaping', 'check']
const STATION_FRACS = [0.07, 0.285, 0.5, 0.715, 0.93]
type Panel = 'chips' | 'band' | 'date' | 'address'
export default function JourneySandboxPage() {
const bandRef = useRef<HTMLDivElement | null>(null)
const [orbState, setOrbState] = useState<OrbState>('listening')
const [active, setActive] = useState(1)
const [morph, setMorph] = useState<string | null>(null)
const [panel, setPanel] = useState<Panel>('chips')
const [bandSpan, setBandSpan] = useState<[number, number] | null>([0, 11])
const [bandLabel, setBandLabel] = useState('1 januari – 31 december')
const [pickedDate, setPickedDate] = useState<string | null>(null)
const [address, setAddress] = useState<string | null>(null)
const answers: (string | null)[] = [
'Nordvik Bygg & Konsult AB',
active > 1 ? 'Kalenderår' : null,
active > 2 ? 'Kvartalsvis' : null,
active > 3 ? 'Faktureringsmetoden' : null,
null,
]
return (
<div className="jny" style={{ background: 'hsl(var(--frame))' }}>
<div className="jny-dawn" style={{ ['--jny-dawn' as string]: String(active / 4) }} />
<div style={{ display: 'flex', justifyContent: 'space-between', padding: '14px 26px', fontSize: 12, color: 'hsl(var(--muted-foreground))' }}>
<span>/sandbox/journey · primitivgalleri · inga TIC-anrop</span>
<span>
{ORB_STATES.map((s) => (
<button
key={s}
type="button"
className="jny-btn-quiet"
style={{ marginLeft: 10, textDecoration: orbState === s ? 'underline' : undefined }}
onClick={() => {
setOrbState(s)
if (s !== 'check') setMorph(null)
}}
>
{s}
</button>
))}
<button
type="button"
className="jny-btn-quiet"
style={{ marginLeft: 10 }}
onClick={() => {
setOrbState('check')
setMorph('N')
}}
>
monogram
</button>
</span>
</div>
<div className="jny-center">
<div ref={bandRef} style={{ width: '100%' }}>
<JourneyTrack
stations={[
{ label: 'Företaget', answer: answers[0] },
{ label: 'Räkenskapsåret', answer: answers[1] },
{ label: 'Momsen', answer: answers[2] },
{ label: 'Bokföringen', answer: answers[3] },
{ label: 'Klart' },
]}
active={active}
onJump={(i) => setActive(i)}
orbLabel={orbState}
>
<JourneyOrb state={orbState} targetX={STATION_FRACS[active]} morphChar={morph} />
</JourneyTrack>
</div>
<div className="jny-facts">
<div className="jny-factname">Nordvik Bygg &amp; Konsult AB</div>
<div className="jny-factmeta">
<span className="jny-f is-on">Aktiebolag</span>
<span className="jny-f is-on"> · Malmö</span>
<span className="jny-f is-on"> · F-skatt</span>
<span className="jny-f is-on"> · Momsregistrerat</span>
</div>
</div>
<div className="jny-qarea">
{panel === 'chips' && (
<Question
title="Stämmer räkenskapsåret?"
sub="Enligt Bolagsverket följer ni kalenderåret: 1 januari till 31 december."
info="De flesta bolag följer kalenderåret. Brutet räkenskapsår slutar en annan månad än december och används mest i koncerner."
>
<ChipRow
options={[
{ key: 'yes', label: 'Ja, kalenderår' },
{ key: 'other', label: 'Annat räkenskapsår' },
{ key: 'first', label: 'Det är vårt första år' },
]}
onPick={(k) => {
if (k === 'other') setPanel('band')
else if (k === 'first') setPanel('date')
else setActive((a) => Math.min(a + 1, 4))
}}
flyTargetRef={bandRef}
flyTargetFrac={STATION_FRACS[active]}
/>
</Question>
)}
{panel === 'band' && (
<Question title="När slutar räkenskapsåret?" sub="Peka för att förhandsgranska.">
<YearBand cells={24} year0={2026} span={bandSpan} label={bandLabel} note={bandSpan?.[0] === 0 ? 'Kalenderår, som de flesta.' : 'Brutet räkenskapsår.'} />
<div className="jny-mchips">
{['jan', 'feb', 'mar', 'apr', 'maj', 'jun', 'jul', 'aug', 'sep', 'okt', 'nov', 'dec'].map((n, i) => (
<button
key={n}
type="button"
className="jny-mchip"
onMouseEnter={() => {
const m = i + 1
if (m === 12) {
setBandSpan([0, 11])
setBandLabel('1 januari – 31 december')
} else {
setBandSpan([m, m + 11])
setBandLabel(`1 ${['januari','februari','mars','april','maj','juni','juli','augusti','september','oktober','november','december'][m]} – ${['januari','februari','mars','april','maj','juni','juli','augusti','september','oktober','november','december'][m - 1]}`)
}
}}
onClick={() => setPanel('address')}
>
{n}
</button>
))}
</div>
</Question>
)}
{panel === 'date' && (
<Question title="När startade bolaget?" sub="Registreringsdatumet hos Bolagsverket.">
<JourneyDatePicker
years={[2025, 2026]}
onPick={(d) => {
setPickedDate(d)
setPanel('address')
}}
/>
{pickedDate ? <p className="jny-qsub" style={{ marginTop: 14 }}>Valt: {pickedDate}</p> : null}
</Question>
)}
{panel === 'address' && (
<Question title="Vad är bolagets adress?">
<AddressFields
placeholders={{ street: 'Gatuadress', postalCode: 'Postnummer', city: 'Ort' }}
enterHint={
<>
Tryck <b>Enter</b> när du är klar
</>
}
skipLabel="Lägg till senare"
onSubmit={(v) => {
setAddress(v.city ?? v.addressLine1 ?? '(hoppade över)')
setPanel('chips')
}}
/>
{address ? <p className="jny-qsub" style={{ marginTop: 14 }}>Sparat: {address}</p> : null}
</Question>
)}
</div>
<div className="jny-balance" aria-hidden="true" />
<div className="jny-backrow">
<button type="button" className="jny-btn-quiet" onClick={() => setPanel('chips')}>
&lsaquo; Tillbaka till chipsen
</button>
</div>
</div>
</div>
)
}