* 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>
206 lines
8.3 KiB
TypeScript
206 lines
8.3 KiB
TypeScript
'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 & 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')}>
|
||
‹ Tillbaka till chipsen
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|