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:
co-authored by
Claude Fable 5
parent
f9ef8913ae
commit
b771c1f923
@@ -353,3 +353,4 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
|
||||
[2026-07-24] BankID company picker in journey onboarding: searchable list (name + form/role + orgnr, "finns redan" section, filter with single-match Enter) is the standard at ANY engagement count; chips variant cut (founder call on concept c82c9358). Picking a company still runs zero Lens lookups: CompanyRoles prefill only, F-skatt/moms/rakenskapsar asked as questions per the plan's lookupRan rule.
|
||||
[2026-07-24] BankID onboarding lookup (SUPERSEDES the zero-Lens line above, founder call): picking a company in the BankID picker DOES run one Lens lookup and prefills facts (address, F-skatt, moms, fiscal year, SNI), same per-onboarding cost as typing an orgnr; the roster list itself stays free CompanyRoles data, never prefetched. lookupRan=false becomes the degradation path (TIC disabled/429/504 -> ask as questions). Implementation note: the journey must NOT carry over Step2's preverifiedOrgNumber lookup-suppression; the deep-linked orgnr triggers the same single lookup as manual entry.
|
||||
[2026-07-24] Rest-of-nav PR 1 (Viktiga datum/Skattekonto/Periodiseringar/Import) deviations from concept scenes 17/24/32/33: skattekonto Saldo column dropped (SKV API stores no per-row running balance; computing one client-side would be dishonest math); Periodiseringar ships without the concept's "Ny periodisering" header button (no standalone create flow exists, accruals are born from invoice rows, help popover explains); Import keeps FY selection inside the SIE-export dialog instead of a header FyPicker (FY only affects SIE export, a page-level picker would lie); concept's "Hela arkivet" export row omitted (no zip-archive endpoint exists).
|
||||
[2026-07-24] Journey reducer lives in lib/onboarding-journey/, not components/onboarding/journey/ as the plan sketched: pure logic belongs in the vitest scope (lib/ + app/api/), and the plan's reducer test matrix requires it. Components stay copy-agnostic; i18n keys land with their consumer in PR C.
|
||||
|
||||
@@ -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 & 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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
'use client'
|
||||
|
||||
import { useRef, useState, type ReactNode } from 'react'
|
||||
|
||||
/**
|
||||
* Three-part address entry (street / postal code / city) with Enter
|
||||
* chaining field to field and submitting from the last one. Skippable:
|
||||
* the address is optional in onboarding, exactly like the wizard.
|
||||
*/
|
||||
interface AddressFieldsProps {
|
||||
placeholders: { street: string; postalCode: string; city: string }
|
||||
enterHint: ReactNode
|
||||
skipLabel: string
|
||||
onSubmit: (v: { addressLine1?: string; postalCode?: string; city?: string }) => void
|
||||
}
|
||||
|
||||
export default function AddressFields({ placeholders, enterHint, skipLabel, onSubmit }: AddressFieldsProps) {
|
||||
const [street, setStreet] = useState('')
|
||||
const [zip, setZip] = useState('')
|
||||
const [city, setCity] = useState('')
|
||||
const zipRef = useRef<HTMLInputElement | null>(null)
|
||||
const cityRef = useRef<HTMLInputElement | null>(null)
|
||||
|
||||
function submit() {
|
||||
onSubmit({
|
||||
addressLine1: street.trim() || undefined,
|
||||
postalCode: zip.trim() || undefined,
|
||||
city: city.trim() || undefined,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="jny-addr">
|
||||
<div className="jny-biginput">
|
||||
<input
|
||||
value={street}
|
||||
placeholder={placeholders.street}
|
||||
aria-label={placeholders.street}
|
||||
autoComplete="off"
|
||||
autoFocus
|
||||
onChange={(e) => setStreet(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && zipRef.current?.focus()}
|
||||
/>
|
||||
</div>
|
||||
<div className="jny-addr-row">
|
||||
<div className="jny-biginput jny-addr-zip">
|
||||
<input
|
||||
ref={zipRef}
|
||||
value={zip}
|
||||
placeholder={placeholders.postalCode}
|
||||
aria-label={placeholders.postalCode}
|
||||
inputMode="numeric"
|
||||
autoComplete="off"
|
||||
onChange={(e) => setZip(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && cityRef.current?.focus()}
|
||||
/>
|
||||
</div>
|
||||
<div className="jny-biginput jny-addr-city">
|
||||
<input
|
||||
ref={cityRef}
|
||||
value={city}
|
||||
placeholder={placeholders.city}
|
||||
aria-label={placeholders.city}
|
||||
autoComplete="off"
|
||||
onChange={(e) => setCity(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && submit()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="jny-enterhint">{enterHint}</p>
|
||||
<div className="jny-qactions">
|
||||
<button type="button" className="jny-btn-quiet" onClick={() => onSubmit({})}>
|
||||
{skipLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
'use client'
|
||||
|
||||
import { useState, type RefObject } from 'react'
|
||||
|
||||
/**
|
||||
* Pill-chip answers. On pick, a ghost of the chip flies into the orb
|
||||
* (the active station's spot on the band) before the flow moves on.
|
||||
* Skipped under prefers-reduced-motion.
|
||||
*/
|
||||
export interface ChipOption<K extends string = string> {
|
||||
key: K
|
||||
label: string
|
||||
/** Small secondary line inside the chip (e.g. "12 månader"). */
|
||||
rec?: string
|
||||
}
|
||||
|
||||
interface ChipRowProps<K extends string> {
|
||||
options: ChipOption<K>[]
|
||||
onPick: (key: K) => void
|
||||
/** The band element the ghost flies toward. */
|
||||
flyTargetRef?: RefObject<HTMLElement | null>
|
||||
/** Horizontal fraction (0-1) of the band where the orb currently sits. */
|
||||
flyTargetFrac?: number
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export function flyToBand(el: HTMLElement, band: HTMLElement | null, frac: number) {
|
||||
if (!band) return
|
||||
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return
|
||||
const r = el.getBoundingClientRect()
|
||||
const c = band.getBoundingClientRect()
|
||||
const tx = c.left + frac * c.width - (r.left + r.width / 2)
|
||||
const ty = c.top + 28 - (r.top + r.height / 2)
|
||||
const ghost = el.cloneNode(true) as HTMLElement
|
||||
ghost.style.cssText = `position:fixed; left:${r.left}px; top:${r.top}px; width:${r.width}px; margin:0; z-index:99; pointer-events:none; border:1px solid hsl(var(--border)); border-radius:99px; padding:10px 18px; background:hsl(var(--background)); font-size:13.5px; transition: transform 520ms cubic-bezier(0.5, 0, 0.2, 1), opacity 520ms var(--ease-out);`
|
||||
document.body.appendChild(ghost)
|
||||
requestAnimationFrame(() => {
|
||||
ghost.style.transform = `translate(${tx}px,${ty}px) scale(0.12)`
|
||||
ghost.style.opacity = '0'
|
||||
})
|
||||
setTimeout(() => ghost.remove(), 560)
|
||||
}
|
||||
|
||||
export default function ChipRow<K extends string>({
|
||||
options,
|
||||
onPick,
|
||||
flyTargetRef,
|
||||
flyTargetFrac = 0,
|
||||
disabled,
|
||||
}: ChipRowProps<K>) {
|
||||
const [picked, setPicked] = useState<K | null>(null)
|
||||
|
||||
return (
|
||||
<div className="jny-chips">
|
||||
{options.map((opt) => (
|
||||
<button
|
||||
key={opt.key}
|
||||
type="button"
|
||||
className={`jny-pick${picked === opt.key ? ' is-sel' : ''}`}
|
||||
disabled={disabled}
|
||||
onClick={(e) => {
|
||||
if (disabled) return
|
||||
setPicked(opt.key)
|
||||
flyToBand(e.currentTarget, flyTargetRef?.current ?? null, flyTargetFrac)
|
||||
onPick(opt.key)
|
||||
}}
|
||||
>
|
||||
{opt.label}
|
||||
{opt.rec ? <span className="jny-rec">{opt.rec}</span> : null}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { InkText } from './ink'
|
||||
|
||||
/**
|
||||
* Tap-only date picker: year → month BY NAME → day. No dd/mm ambiguity.
|
||||
* Fires onPick("YYYY-MM-DD") shortly after the day is tapped (immediately
|
||||
* under reduced motion).
|
||||
*/
|
||||
const MONTHS_SV = [
|
||||
'januari', 'februari', 'mars', 'april', 'maj', 'juni',
|
||||
'juli', 'augusti', 'september', 'oktober', 'november', 'december',
|
||||
]
|
||||
const MONTHS_SHORT = ['jan', 'feb', 'mar', 'apr', 'maj', 'jun', 'jul', 'aug', 'sep', 'okt', 'nov', 'dec']
|
||||
|
||||
function lastDay(y: number, m: number) {
|
||||
return new Date(y, m, 0).getDate()
|
||||
}
|
||||
|
||||
interface JourneyDatePickerProps {
|
||||
years: number[]
|
||||
onPick: (date: string) => void
|
||||
}
|
||||
|
||||
export default function JourneyDatePicker({ years, onPick }: JourneyDatePickerProps) {
|
||||
const [y, setY] = useState<number>(years[years.length - 1])
|
||||
const [m, setM] = useState<number>(0)
|
||||
const [d, setD] = useState<number>(0)
|
||||
|
||||
function pickDay(day: number) {
|
||||
setD(day)
|
||||
const iso = `${y}-${String(m).padStart(2, '0')}-${String(day).padStart(2, '0')}`
|
||||
const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||
window.setTimeout(() => onPick(iso), reduced ? 0 : 650)
|
||||
}
|
||||
|
||||
const label = m && d ? `${d} ${MONTHS_SV[m - 1]} ${y}` : m ? `${MONTHS_SV[m - 1]} ${y}` : ''
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="jny-mchips" style={{ marginTop: 10 }}>
|
||||
{years.map((yy) => (
|
||||
<button
|
||||
key={yy}
|
||||
type="button"
|
||||
className={`jny-mchip${yy === y ? ' is-sel' : ''}`}
|
||||
onClick={() => {
|
||||
setY(yy)
|
||||
setD(0)
|
||||
}}
|
||||
>
|
||||
{yy}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="jny-mchips">
|
||||
{MONTHS_SHORT.map((name, i) => (
|
||||
<button
|
||||
key={name}
|
||||
type="button"
|
||||
className={`jny-mchip${i + 1 === m ? ' is-sel' : ''}`}
|
||||
onClick={() => {
|
||||
setM(i + 1)
|
||||
setD(0)
|
||||
}}
|
||||
>
|
||||
{name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{m ? (
|
||||
<div className="jny-mchips jny-dp-days">
|
||||
{Array.from({ length: lastDay(y, m) }, (_, i) => i + 1).map((day) => (
|
||||
<button
|
||||
key={day}
|
||||
type="button"
|
||||
className={`jny-mchip${day === d ? ' is-sel' : ''}`}
|
||||
onClick={() => pickDay(day)}
|
||||
>
|
||||
{day}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="jny-dp-label">{label ? <InkText text={label} step={40} /> : null}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef } from 'react'
|
||||
|
||||
/**
|
||||
* The journey's particle orb, ported from the founder-approved concept.
|
||||
* A 320-particle sphere rides the journey band: it stretches into a comet
|
||||
* while traveling, morphs into a checkmark when the company is created,
|
||||
* and can re-morph into the company's serif initial (the monogram finale).
|
||||
*
|
||||
* Deliberately its own canvas component (NOT the thinking-orbs package:
|
||||
* that lacks the comet and the shape morphs; decision logged in the plan).
|
||||
*
|
||||
* - rAF pauses while the tab is hidden.
|
||||
* - Reduced motion renders static frames on state changes (jump, no glide).
|
||||
*/
|
||||
|
||||
export type OrbState = 'listening' | 'searching' | 'thinking' | 'working' | 'shaping' | 'check'
|
||||
|
||||
interface JourneyOrbProps {
|
||||
/** Animation mode; drives speed, wobble and the check morph. */
|
||||
state: OrbState
|
||||
/** Horizontal target as a fraction (0-1) of the band width. */
|
||||
targetX: number
|
||||
/** When set during `check`, the particles re-morph into this character. */
|
||||
morphChar?: string | null
|
||||
/** Band height in px (canvas height). */
|
||||
height?: number
|
||||
}
|
||||
|
||||
const N = 320
|
||||
const SIZE = 56
|
||||
const TN = 66
|
||||
|
||||
function buildSphere() {
|
||||
const pts: { x: number; y: number; z: number; ph: number; lag: number }[] = []
|
||||
for (let i = 0; i < N; i++) {
|
||||
const y = 1 - (i / (N - 1)) * 2
|
||||
const r = Math.sqrt(1 - y * y)
|
||||
const th = i * 2.39996323
|
||||
pts.push({ x: Math.cos(th) * r, y, z: Math.sin(th) * r, ph: Math.random() * 6.283, lag: Math.random() })
|
||||
}
|
||||
return pts
|
||||
}
|
||||
|
||||
function buildCheck(): [number, number][] {
|
||||
const chk: [number, number][] = []
|
||||
const A = [-0.75, 0.1]
|
||||
const B = [-0.15, 0.65]
|
||||
const C = [0.85, -0.6]
|
||||
const lenAB = Math.hypot(B[0] - A[0], B[1] - A[1])
|
||||
const lenBC = Math.hypot(C[0] - B[0], C[1] - B[1])
|
||||
const cut = lenAB / (lenAB + lenBC)
|
||||
for (let j = 0; j < TN; j++) {
|
||||
const t = j / (TN - 1)
|
||||
if (t < cut) {
|
||||
const f = t / cut
|
||||
chk.push([A[0] + (B[0] - A[0]) * f, A[1] + (B[1] - A[1]) * f])
|
||||
} else {
|
||||
const f = (t - cut) / (1 - cut)
|
||||
chk.push([B[0] + (C[0] - B[0]) * f, B[1] + (C[1] - B[1]) * f])
|
||||
}
|
||||
}
|
||||
return chk
|
||||
}
|
||||
|
||||
/** Sample a serif glyph into particle targets, normalized to roughly [-1, 1]. */
|
||||
function glyphTargets(ch: string, fontFamily: string): [number, number][] | null {
|
||||
const gc = document.createElement('canvas')
|
||||
gc.width = gc.height = 120
|
||||
const g = gc.getContext('2d')
|
||||
if (!g) return null
|
||||
g.fillStyle = '#000'
|
||||
g.font = `100px ${fontFamily}`
|
||||
g.textAlign = 'center'
|
||||
g.textBaseline = 'middle'
|
||||
g.fillText(ch, 60, 68)
|
||||
const data = g.getImageData(0, 0, 120, 120).data
|
||||
let pts: [number, number][] = []
|
||||
for (let y = 2; y < 118; y += 3) {
|
||||
for (let x = 2; x < 118; x += 3) {
|
||||
if (data[(y * 120 + x) * 4 + 3] > 120) pts.push([(x - 60) / 52, (y - 60) / 52])
|
||||
}
|
||||
}
|
||||
if (pts.length > 150) {
|
||||
const step = pts.length / 150
|
||||
const out: [number, number][] = []
|
||||
for (let k = 0; k < 150; k++) out.push(pts[Math.floor(k * step)])
|
||||
pts = out
|
||||
}
|
||||
return pts.length ? pts : null
|
||||
}
|
||||
|
||||
export default function JourneyOrb({ state, targetX, morphChar, height = 112 }: JourneyOrbProps) {
|
||||
const canvasRef = useRef<HTMLCanvasElement | null>(null)
|
||||
const stateRef = useRef<OrbState>(state)
|
||||
const targetRef = useRef(targetX)
|
||||
const morphRef = useRef<string | null>(morphChar ?? null)
|
||||
const kickRef = useRef<() => void>(() => {})
|
||||
|
||||
useEffect(() => {
|
||||
stateRef.current = state
|
||||
targetRef.current = targetX
|
||||
morphRef.current = morphChar ?? null
|
||||
kickRef.current()
|
||||
}, [state, targetX, morphChar])
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current
|
||||
if (!canvas) return
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) return
|
||||
const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||
const dpr = Math.min(window.devicePixelRatio || 1, 2)
|
||||
const CY = height / 4
|
||||
let W = 0
|
||||
|
||||
const pts = buildSphere()
|
||||
const CHK = buildCheck()
|
||||
let shapeTarget: [number, number][] = CHK
|
||||
let SN = TN
|
||||
const shapeCur: [number, number][] = CHK.map((p) => [p[0], p[1]])
|
||||
let appliedMorph: string | null = null
|
||||
|
||||
function setShape(next: [number, number][]) {
|
||||
const n = Math.min(next.length, N)
|
||||
while (shapeCur.length < n) {
|
||||
const base = shapeCur[shapeCur.length % TN]
|
||||
shapeCur.push([base[0], base[1]])
|
||||
}
|
||||
shapeTarget = next
|
||||
SN = n
|
||||
if (reduced) {
|
||||
for (let q = 0; q < SN; q++) {
|
||||
shapeCur[q][0] = next[q][0]
|
||||
shapeCur[q][1] = next[q][1]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function resize() {
|
||||
const parent = canvas!.parentElement
|
||||
W = parent ? parent.clientWidth : 0
|
||||
canvas!.width = W * dpr
|
||||
canvas!.height = height * dpr
|
||||
canvas!.style.width = `${W}px`
|
||||
canvas!.style.height = `${height}px`
|
||||
}
|
||||
resize()
|
||||
|
||||
let t = 0
|
||||
let last = 0
|
||||
let shapeMix = 0
|
||||
let cx = targetRef.current
|
||||
let vel = 0
|
||||
let raf = 0
|
||||
let running = false
|
||||
|
||||
function speed(s: OrbState) {
|
||||
return s === 'working' ? 2.6 : s === 'searching' ? 1.4 : s === 'thinking' ? 0.9 : s === 'check' ? 0.2 : 0.35
|
||||
}
|
||||
|
||||
function frame(now: number) {
|
||||
const s = stateRef.current
|
||||
const dt = Math.min((now - last) / 1000, 0.05)
|
||||
last = now
|
||||
t += dt * speed(s)
|
||||
|
||||
// Shape bookkeeping: default check; monogram only while checked.
|
||||
const wantMorph = s === 'check' ? morphRef.current : null
|
||||
if (wantMorph !== appliedMorph) {
|
||||
appliedMorph = wantMorph
|
||||
if (wantMorph) {
|
||||
const fontFamily =
|
||||
getComputedStyle(document.documentElement).getPropertyValue('--font-display') ||
|
||||
'Georgia, serif'
|
||||
const glyph = glyphTargets(wantMorph, fontFamily)
|
||||
if (glyph) setShape(glyph)
|
||||
} else {
|
||||
setShape(CHK)
|
||||
}
|
||||
}
|
||||
|
||||
const prev = cx
|
||||
cx += (targetRef.current - cx) * Math.min(dt * 4.2, 1)
|
||||
if (reduced) cx = targetRef.current
|
||||
vel = ((cx - prev) * W) / Math.max(dt, 0.001)
|
||||
|
||||
const wantShape = s === 'shaping' || s === 'check' ? 1 : 0
|
||||
shapeMix += (wantShape - shapeMix) * Math.min(dt * 4, 1)
|
||||
if (reduced) shapeMix = wantShape
|
||||
if (shapeMix > 0.01) {
|
||||
const sk = Math.min(dt * 5, 1)
|
||||
for (let q = 0; q < SN; q++) {
|
||||
shapeCur[q][0] += (shapeTarget[q][0] - shapeCur[q][0]) * sk
|
||||
shapeCur[q][1] += (shapeTarget[q][1] - shapeCur[q][1]) * sk
|
||||
}
|
||||
}
|
||||
|
||||
const col = getComputedStyle(canvas!).color
|
||||
ctx!.clearRect(0, 0, canvas!.width, canvas!.height)
|
||||
ctx!.fillStyle = col
|
||||
const R = (SIZE / 2 - 3) * dpr
|
||||
const cxp = cx * W * dpr
|
||||
const cyp = CY * dpr
|
||||
const cosT = Math.cos(t)
|
||||
const sinT = Math.sin(t)
|
||||
const stretch = reduced ? 0 : Math.min(Math.abs(vel) / 900, 1.6)
|
||||
const dir = vel >= 0 ? 1 : -1
|
||||
|
||||
for (let i = 0; i < N; i++) {
|
||||
const p = pts[i]
|
||||
let px = p.x
|
||||
let py = p.y
|
||||
let pz = p.z
|
||||
if (s === 'thinking') {
|
||||
const band = Math.sin(py * 6.5 + t * 2.4) * 0.45
|
||||
const c2 = Math.cos(band)
|
||||
const s2 = Math.sin(band)
|
||||
const nx = px * c2 - pz * s2
|
||||
pz = px * s2 + pz * c2
|
||||
px = nx
|
||||
}
|
||||
if (s === 'working') {
|
||||
px += Math.sin(t * 3.1 + p.ph) * 0.34
|
||||
py += Math.cos(t * 2.7 + p.ph * 1.7) * 0.34
|
||||
pz += Math.sin(t * 2.2 + p.ph * 2.3) * 0.3
|
||||
}
|
||||
const rx = px * cosT + pz * sinT
|
||||
const rz = -px * sinT + pz * cosT
|
||||
const depth = (rz + 1) / 2
|
||||
const lagPx = stretch * R * (0.35 + p.lag * 1.5) * (0.4 + 0.6 * depth) * dir
|
||||
const squash = 1 / (1 + stretch * 0.25)
|
||||
let sx = cxp + rx * R * 0.92 * (1 + stretch * 0.08) - lagPx
|
||||
let sy = cyp + py * R * 0.92 * squash
|
||||
if (shapeMix > 0.01 && i < SN) {
|
||||
const tp = shapeCur[i]
|
||||
sx = sx + (cxp + tp[0] * R * 1.05 - sx) * shapeMix
|
||||
sy = sy + (cyp + tp[1] * R * 1.05 - sy) * shapeMix
|
||||
}
|
||||
const a = shapeMix > 0.5 && i >= SN ? 1 - shapeMix : 0.2 + 0.75 * depth
|
||||
if (a <= 0.02) continue
|
||||
ctx!.globalAlpha = a
|
||||
const rad = (0.55 + 0.75 * depth) * dpr
|
||||
ctx!.beginPath()
|
||||
ctx!.arc(sx, sy, rad, 0, 6.2832)
|
||||
ctx!.fill()
|
||||
}
|
||||
ctx!.globalAlpha = 1
|
||||
|
||||
if (!reduced && running && !document.hidden) {
|
||||
raf = requestAnimationFrame(frame)
|
||||
} else {
|
||||
running = false
|
||||
}
|
||||
}
|
||||
|
||||
function start() {
|
||||
if (reduced) {
|
||||
frame(last + 16)
|
||||
return
|
||||
}
|
||||
if (running || document.hidden) return
|
||||
running = true
|
||||
raf = requestAnimationFrame((now) => {
|
||||
last = now
|
||||
raf = requestAnimationFrame(frame)
|
||||
})
|
||||
}
|
||||
kickRef.current = start
|
||||
|
||||
function onVisibility() {
|
||||
if (!document.hidden) start()
|
||||
}
|
||||
function onResize() {
|
||||
resize()
|
||||
if (reduced) frame(last + 16)
|
||||
}
|
||||
document.addEventListener('visibilitychange', onVisibility)
|
||||
window.addEventListener('resize', onResize)
|
||||
start()
|
||||
|
||||
return () => {
|
||||
running = false
|
||||
cancelAnimationFrame(raf)
|
||||
document.removeEventListener('visibilitychange', onVisibility)
|
||||
window.removeEventListener('resize', onResize)
|
||||
kickRef.current = () => {}
|
||||
}
|
||||
// The loop reads live values via refs; height is the only real dependency.
|
||||
}, [height])
|
||||
|
||||
return <canvas ref={canvasRef} className="jny-canvas" aria-hidden="true" />
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
'use client'
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
import { InkText } from './ink'
|
||||
|
||||
/**
|
||||
* The five journey stations (Företaget → Klart): dots, uppercase labels and
|
||||
* the inked-in answers beneath completed stations. No connecting lines.
|
||||
* Completed stations are jump-back buttons (plan: rewind to the station's
|
||||
* first question). Labels/answers hide under 640px; answers are mirrored
|
||||
* into a visually hidden aria-live region.
|
||||
*/
|
||||
|
||||
export interface TrackStation {
|
||||
label: string
|
||||
answer?: string | null
|
||||
}
|
||||
|
||||
interface JourneyTrackProps {
|
||||
stations: TrackStation[]
|
||||
/** Index of the active station (0-4). Earlier stations render as done. */
|
||||
active: number
|
||||
onJump?: (station: number) => void
|
||||
/** The orb canvas (positioned absolutely inside the band). */
|
||||
children?: ReactNode
|
||||
/** Screen-reader description of what the orb is doing. */
|
||||
orbLabel?: string
|
||||
}
|
||||
|
||||
const LEFTS = ['7.0%', '28.5%', '50.0%', '71.5%', '93.0%']
|
||||
|
||||
export default function JourneyTrack({ stations, active, onJump, children, orbLabel }: JourneyTrackProps) {
|
||||
return (
|
||||
<div className="jny-journey" aria-label="Steg i onboardingen">
|
||||
{stations.map((st, i) => {
|
||||
const done = i < active
|
||||
return (
|
||||
<div
|
||||
key={st.label}
|
||||
className={`jny-pt${done ? ' is-done' : ''}${i === active ? ' is-active' : ''}`}
|
||||
style={{ left: LEFTS[i] ?? LEFTS[LEFTS.length - 1] }}
|
||||
role={done && onJump ? 'button' : undefined}
|
||||
tabIndex={done && onJump ? 0 : -1}
|
||||
title={done && onJump ? 'Ändra' : undefined}
|
||||
onClick={done && onJump ? () => onJump(i) : undefined}
|
||||
onKeyDown={
|
||||
done && onJump
|
||||
? (e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
onJump(i)
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<span className="jny-dot" />
|
||||
<span className="jny-lbl">{st.label}</span>
|
||||
<span className="jny-ans">
|
||||
{st.answer ? <InkText text={st.answer} step={40} /> : null}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{children}
|
||||
<span className="sr-only" aria-live="polite">
|
||||
{orbLabel}
|
||||
{stations
|
||||
.filter((s) => s.answer)
|
||||
.map((s) => `${s.label}: ${s.answer}`)
|
||||
.join('. ')}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useId, useState, type ReactNode } from 'react'
|
||||
import { InkText } from './ink'
|
||||
|
||||
/**
|
||||
* Question scaffold: serif inked title, optional sub line, optional "?"
|
||||
* help popover (Esc closes), optional one-line attention text (.jny-attn),
|
||||
* and the answer surface as children. One question on screen at a time.
|
||||
*/
|
||||
interface QuestionProps {
|
||||
title: string
|
||||
sub?: ReactNode
|
||||
info?: ReactNode
|
||||
attn?: ReactNode
|
||||
children?: ReactNode
|
||||
}
|
||||
|
||||
export default function Question({ title, sub, info, attn, children }: QuestionProps) {
|
||||
const [infoOpen, setInfoOpen] = useState(false)
|
||||
const infoId = useId()
|
||||
|
||||
// A new question closes the previous question's popover (render-time
|
||||
// derived-state reset, the lint-sanctioned pattern).
|
||||
const [lastTitle, setLastTitle] = useState(title)
|
||||
if (title !== lastTitle) {
|
||||
setLastTitle(title)
|
||||
setInfoOpen(false)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!infoOpen) return
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') setInfoOpen(false)
|
||||
}
|
||||
window.addEventListener('keydown', onKey)
|
||||
return () => window.removeEventListener('keydown', onKey)
|
||||
}, [infoOpen])
|
||||
|
||||
return (
|
||||
<div className="jny-qstep" key={title}>
|
||||
<h1 className="jny-qtitle">
|
||||
<InkText text={title} />
|
||||
</h1>
|
||||
{(sub || info) && (
|
||||
<p className="jny-qsub">
|
||||
{sub}
|
||||
{info ? (
|
||||
<button
|
||||
type="button"
|
||||
className={`jny-qhelp${infoOpen ? ' is-on' : ''}`}
|
||||
aria-label="Mer information"
|
||||
aria-expanded={infoOpen}
|
||||
aria-controls={infoId}
|
||||
onClick={() => setInfoOpen((v) => !v)}
|
||||
>
|
||||
?
|
||||
</button>
|
||||
) : null}
|
||||
</p>
|
||||
)}
|
||||
{info && infoOpen ? (
|
||||
<p className="jny-qinfo" id={infoId}>
|
||||
{info}
|
||||
</p>
|
||||
) : null}
|
||||
{attn ? <p className="jny-attn">{attn}</p> : null}
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
'use client'
|
||||
|
||||
import { InkText } from './ink'
|
||||
|
||||
/**
|
||||
* The fiscal-year band: 24 or 36 month cells with a springy highlight span,
|
||||
* a serif label ("1 januari – 31 december") and a small note line.
|
||||
* Pure display; the parent drives span/label/note from hover or selection.
|
||||
*/
|
||||
const MONTH_LETTERS = ['J', 'F', 'M', 'A', 'M', 'J', 'J', 'A', 'S', 'O', 'N', 'D']
|
||||
|
||||
interface YearBandProps {
|
||||
cells?: 24 | 36
|
||||
year0: number
|
||||
/** Inclusive month-cell indices to highlight, or null for none. */
|
||||
span?: [number, number] | null
|
||||
label?: string
|
||||
note?: string
|
||||
}
|
||||
|
||||
export default function YearBand({ cells = 24, year0, span, label, note }: YearBandProps) {
|
||||
const years = Array.from({ length: cells / 12 }, (_, i) => year0 + i)
|
||||
return (
|
||||
<div className="jny-yband">
|
||||
<div className="jny-yb-grid">
|
||||
<div
|
||||
className={`jny-yb-span${span ? ' is-on' : ''}`}
|
||||
style={
|
||||
span
|
||||
? {
|
||||
left: `${(span[0] / cells) * 100}%`,
|
||||
width: `${((span[1] - span[0] + 1) / cells) * 100}%`,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<div className="jny-yb-cells" style={{ gridTemplateColumns: `repeat(${cells}, 1fr)` }}>
|
||||
{Array.from({ length: cells }, (_, i) => (
|
||||
<span key={i} className={span && i >= span[0] && i <= span[1] ? 'is-in' : undefined}>
|
||||
{MONTH_LETTERS[i % 12]}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="jny-yb-years" style={{ gridTemplateColumns: `repeat(${cells}, 1fr)` }}>
|
||||
{years.map((y) => (
|
||||
<span key={y} style={{ gridColumn: 'span 12' }}>
|
||||
{y}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="jny-yb-label">{label ? <InkText text={label} step={40} /> : null}</div>
|
||||
<div className="jny-yb-note">{note}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
'use client'
|
||||
|
||||
/** Word-by-word ink-dry text: each word blurs in with a slight stagger.
|
||||
* Keyed by the text so a changed string re-inks. */
|
||||
export function InkText({ text, step = 70 }: { text: string; step?: number }) {
|
||||
return (
|
||||
<span key={text}>
|
||||
{text.split(' ').map((w, i) => (
|
||||
<span key={`${i}-${w}`} className="jny-ink" style={{ animationDelay: `${i * step}ms` }}>
|
||||
{w}
|
||||
</span>
|
||||
)).reduce<React.ReactNode[]>((acc, node, i) => (i === 0 ? [node] : [...acc, ' ', node]), [])}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
/* Journey onboarding styles, ported from the founder-approved concept
|
||||
(artifact c82c9358). Namespaced under .jny; uses the app's tokens
|
||||
(--font-display, --ease-out, --ease-spring, color tokens).
|
||||
Motion here deliberately deviates from the dashboard motion rules:
|
||||
the concept was signed off with springs, ink and the particle orb. */
|
||||
|
||||
/* ── app-shell composition: the page never scrolls ───────────────── */
|
||||
.jny {
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
min-height: 540px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
}
|
||||
.jny-center {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
width: 100%;
|
||||
max-width: 640px;
|
||||
margin: 0 auto;
|
||||
padding: 1vh 24px 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
}
|
||||
/* optical centering: mirrors the top block below the question and
|
||||
collapses first when a tall step needs the room */
|
||||
.jny-balance { flex: 0 20 180px; min-height: 0; }
|
||||
.jny-backrow { flex: none; margin: 8px 0 12px; min-height: 20px; }
|
||||
|
||||
/* ── journey band ────────────────────────────────────────────────── */
|
||||
.jny-journey { flex: none; position: relative; width: min(680px, 94vw); margin: 6px auto 0; height: 112px; }
|
||||
.jny-pt { position: absolute; top: 23px; transform: translateX(-50%); text-align: center; width: 150px; }
|
||||
.jny-dot { display: block; width: 9px; height: 9px; margin: 0 auto; border-radius: 99px; border: 1px solid hsl(var(--border)); background: hsl(var(--background)); transition: background-color 300ms var(--ease-out), border-color 300ms var(--ease-out); }
|
||||
.jny-pt.is-done .jny-dot { background: hsl(var(--success)); border-color: hsl(var(--success)); animation: jnyDotPop 460ms var(--ease-spring); }
|
||||
.jny-pt.is-active .jny-dot { opacity: 0; }
|
||||
.jny-lbl { display: block; margin-top: 28px; font-size: 10.5px; color: hsl(var(--muted-foreground)); letter-spacing: 0.05em; text-transform: uppercase; transition: color 300ms var(--ease-out); }
|
||||
.jny-pt.is-done .jny-lbl, .jny-pt.is-active .jny-lbl { color: hsl(var(--foreground)); }
|
||||
.jny-pt.is-done { cursor: pointer; }
|
||||
.jny-pt.is-done:hover .jny-lbl { text-decoration: underline dotted; text-underline-offset: 3px; }
|
||||
.jny-pt:focus-visible { outline: 2px solid hsl(var(--primary)); outline-offset: 4px; border-radius: 8px; }
|
||||
.jny-ans { display: block; margin-top: 3px; font-size: 11.5px; color: hsl(var(--muted-foreground)); line-height: 1.45; }
|
||||
.jny-canvas { position: absolute; inset: 0; pointer-events: none; z-index: 2; }
|
||||
@keyframes jnyDotPop { 0% { transform: scale(1); } 45% { transform: scale(1.8); } 100% { transform: scale(1); } }
|
||||
@media (max-width: 640px) { .jny-lbl, .jny-ans { display: none; } }
|
||||
|
||||
/* ── fact line (lookup results ink in under the band) ────────────── */
|
||||
.jny-facts { flex: none; margin: 18px 0 0; min-height: 48px; max-width: 540px; transition: opacity 500ms var(--ease-out); }
|
||||
.jny-facts.is-gone { opacity: 0; pointer-events: none; min-height: 0; height: 0; margin: 0; overflow: hidden; }
|
||||
.jny-factname { font-family: var(--font-display); font-size: 18px; letter-spacing: -0.01em; min-height: 26px; }
|
||||
.jny-factmeta { font-size: 12.5px; color: hsl(var(--muted-foreground)); margin-top: 5px; line-height: 1.7; }
|
||||
.jny-factmeta .jny-f { opacity: 0; transition: opacity 600ms var(--ease-out); }
|
||||
.jny-factmeta .jny-f.is-on { opacity: 1; }
|
||||
.jny-factmeta .jny-f.is-warn { color: hsl(var(--destructive)); }
|
||||
|
||||
/* ── ink typography ──────────────────────────────────────────────── */
|
||||
.jny-ink { display: inline-block; animation: jnyInkDry 620ms var(--ease-out) both; }
|
||||
@keyframes jnyInkDry {
|
||||
from { opacity: 0; filter: blur(7px); transform: translateY(5px); }
|
||||
55% { opacity: 1; }
|
||||
to { opacity: 1; filter: blur(0); transform: none; }
|
||||
}
|
||||
|
||||
/* ── question area ───────────────────────────────────────────────── */
|
||||
.jny-qarea {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
padding: 10px 0;
|
||||
}
|
||||
.jny-qstep { max-height: 100%; overflow-y: auto; overscroll-behavior: contain; animation: jnyQIn 380ms var(--ease-out); }
|
||||
@keyframes jnyQIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: none; } }
|
||||
.jny-qtitle {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 400;
|
||||
font-size: clamp(24px, 3.4vw, 32px);
|
||||
line-height: 1.25;
|
||||
letter-spacing: -0.01em;
|
||||
margin: 0 0 8px;
|
||||
text-wrap: balance;
|
||||
}
|
||||
.jny-qsub { font-size: 13px; color: hsl(var(--muted-foreground)); margin: 0 0 26px; }
|
||||
.jny-attn { font-size: 12.5px; color: hsl(var(--warning)); margin: -14px 0 20px; }
|
||||
.jny-qhelp {
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
width: 17px; height: 17px; border-radius: 99px;
|
||||
border: 1px solid hsl(var(--border));
|
||||
color: hsl(var(--muted-foreground));
|
||||
font-size: 11px; line-height: 1;
|
||||
margin-left: 7px; vertical-align: -3px;
|
||||
background: none; cursor: pointer;
|
||||
transition: background-color 150ms var(--ease-out), color 150ms var(--ease-out), border-color 150ms var(--ease-out);
|
||||
}
|
||||
.jny-qhelp:hover, .jny-qhelp.is-on { color: hsl(var(--foreground)); border-color: hsl(var(--foreground) / 0.35); background: hsl(var(--secondary) / 0.5); }
|
||||
.jny-qinfo { font-size: 12.5px; color: hsl(var(--muted-foreground)); max-width: 52ch; margin: -14px auto 26px; line-height: 1.65; animation: jnyQIn 300ms var(--ease-out); }
|
||||
|
||||
/* ── inputs ──────────────────────────────────────────────────────── */
|
||||
.jny-biginput { position: relative; border-bottom: 1px solid hsl(var(--border)); padding: 8px 0 12px; max-width: 360px; margin: 0 auto; transition: border-color 200ms var(--ease-out); }
|
||||
.jny-biginput:focus-within { border-color: hsl(var(--foreground) / 0.55); }
|
||||
.jny-biginput input {
|
||||
width: 100%; border: 0; outline: 0; background: none;
|
||||
font: inherit; color: inherit;
|
||||
font-size: 26px; letter-spacing: 0.04em; font-variant-numeric: tabular-nums;
|
||||
text-align: center;
|
||||
}
|
||||
.jny-biginput input::placeholder { color: hsl(var(--muted-foreground) / 0.45); }
|
||||
.jny-biginput.is-err { animation: jnyShake 340ms var(--ease-out); }
|
||||
@keyframes jnyShake { 0%, 100% { transform: none; } 20%, 60% { transform: translateX(-5px); } 40%, 80% { transform: translateX(5px); } }
|
||||
.jny-enterhint { font-size: 11.5px; color: hsl(var(--muted-foreground)); margin-top: 10px; }
|
||||
.jny-enterhint b { border: 1px solid hsl(var(--border)); border-radius: 5px; padding: 1px 6px; font-weight: 400; }
|
||||
|
||||
.jny-addr { max-width: 380px; margin: 26px auto 0; }
|
||||
.jny-addr .jny-biginput { max-width: none; margin: 0; }
|
||||
.jny-addr input { font-size: 19px; letter-spacing: 0.01em; text-align: center; }
|
||||
.jny-addr-row { display: flex; gap: 26px; margin-top: 4px; }
|
||||
.jny-addr-zip { flex: 0 0 130px; }
|
||||
.jny-addr-city { flex: 1; }
|
||||
|
||||
/* ── chips ───────────────────────────────────────────────────────── */
|
||||
.jny-chips { display: flex; flex-wrap: wrap; gap: 10px; justify-content: center; max-width: 520px; margin: 0 auto; }
|
||||
.jny-pick {
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 99px;
|
||||
padding: 10px 18px;
|
||||
font-size: 13.5px;
|
||||
font-family: inherit;
|
||||
color: inherit;
|
||||
background: hsl(var(--background));
|
||||
cursor: pointer;
|
||||
transition: background-color 150ms var(--ease-out), transform 180ms var(--ease-out);
|
||||
}
|
||||
.jny-pick:hover { background: hsl(var(--secondary) / 0.6); transform: translateY(-1px); }
|
||||
.jny-pick:active { transform: translateY(0) scale(0.96); }
|
||||
.jny-pick.is-sel { background: hsl(var(--primary)); color: hsl(var(--primary-foreground)); border-color: hsl(var(--primary)); }
|
||||
.jny-pick .jny-rec { font-size: 10.5px; color: hsl(var(--muted-foreground)); display: block; }
|
||||
.jny-pick.is-sel .jny-rec { color: hsl(var(--primary-foreground) / 0.7); }
|
||||
.jny-qactions { display: flex; gap: 18px; align-items: center; justify-content: center; margin-top: 30px; }
|
||||
.jny-btn {
|
||||
display: inline-flex; align-items: center; gap: 7px;
|
||||
border-radius: 99px; padding: 8px 22px; border: 0;
|
||||
font-size: 13px; font-weight: 500; font-family: inherit; cursor: pointer;
|
||||
background: hsl(var(--primary)); color: hsl(var(--primary-foreground));
|
||||
transition: background-color 150ms var(--ease-out);
|
||||
}
|
||||
.jny-btn:hover { background: hsl(var(--primary) / 0.85); }
|
||||
.jny-btn-quiet { border: 0; background: none; cursor: pointer; font-family: inherit; color: hsl(var(--muted-foreground)); font-size: 12.5px; }
|
||||
.jny-btn-quiet:hover { color: hsl(var(--foreground)); }
|
||||
|
||||
/* ── fiscal-year band ────────────────────────────────────────────── */
|
||||
.jny-yband { max-width: 500px; margin: 4px auto 0; }
|
||||
.jny-yb-grid { position: relative; padding: 4px 0 0; }
|
||||
.jny-yb-cells { position: relative; display: grid; z-index: 1; }
|
||||
.jny-yb-cells span { font-size: 10px; color: hsl(var(--muted-foreground) / 0.65); line-height: 28px; font-variant-numeric: tabular-nums; transition: color 350ms var(--ease-out); }
|
||||
.jny-yb-cells span.is-in { color: hsl(var(--foreground)); font-weight: 500; }
|
||||
.jny-yb-span {
|
||||
position: absolute; top: 5px; height: 26px; z-index: 0; opacity: 0;
|
||||
border-radius: 99px;
|
||||
background: hsl(var(--secondary) / 0.8);
|
||||
border: 1px solid hsl(var(--border));
|
||||
transition: left 480ms var(--ease-spring), width 480ms var(--ease-spring), opacity 250ms var(--ease-out);
|
||||
}
|
||||
.jny-yb-span.is-on { opacity: 1; }
|
||||
.jny-yb-years { display: grid; margin-top: 3px; }
|
||||
.jny-yb-years span { font-size: 10px; color: hsl(var(--muted-foreground) / 0.55); text-align: left; padding-left: 3px; }
|
||||
.jny-yb-label { font-family: var(--font-display); font-size: 17px; margin-top: 14px; min-height: 25px; }
|
||||
.jny-yb-note { font-size: 12px; color: hsl(var(--muted-foreground)); margin-top: 5px; min-height: 18px; }
|
||||
|
||||
.jny-mchips { display: flex; flex-wrap: wrap; gap: 8px; justify-content: center; max-width: 460px; margin: 18px auto 0; }
|
||||
.jny-mchip {
|
||||
border: 1px solid hsl(var(--border)); border-radius: 99px;
|
||||
padding: 5px 12px; font-size: 12px;
|
||||
font-family: inherit; color: inherit; cursor: pointer;
|
||||
background: hsl(var(--background));
|
||||
transition: background-color 150ms var(--ease-out), transform 150ms var(--ease-out);
|
||||
}
|
||||
.jny-mchip:hover { background: hsl(var(--secondary) / 0.6); transform: translateY(-1px); }
|
||||
.jny-mchip:active { transform: translateY(0) scale(0.96); }
|
||||
.jny-mchip.is-sel { background: hsl(var(--primary)); color: hsl(var(--primary-foreground)); border-color: hsl(var(--primary)); }
|
||||
.jny-dp-days { max-width: 420px; gap: 6px; }
|
||||
.jny-dp-days .jny-mchip { min-width: 32px; padding: 4px 0; font-variant-numeric: tabular-nums; }
|
||||
.jny-dp-label { font-family: var(--font-display); font-size: 17px; margin-top: 16px; min-height: 25px; }
|
||||
|
||||
/* ── company picker rows (BankID roster) ─────────────────────────── */
|
||||
.jny-rowlist {
|
||||
max-width: 470px; margin: 18px auto 0; text-align: left;
|
||||
max-height: 42vh; overflow-y: auto; overscroll-behavior: contain; padding: 4px;
|
||||
-webkit-mask-image: linear-gradient(to bottom, transparent 0, black 14px, black calc(100% - 14px), transparent 100%);
|
||||
mask-image: linear-gradient(to bottom, transparent 0, black 14px, black calc(100% - 14px), transparent 100%);
|
||||
}
|
||||
.jny-rowsec { font-size: 10.5px; letter-spacing: 0.05em; text-transform: uppercase; color: hsl(var(--muted-foreground)); margin: 22px 14px 4px; }
|
||||
.jny-rowpick { display: flex; align-items: baseline; justify-content: space-between; gap: 14px; width: 100%; padding: 11px 14px; border: 0; border-radius: 12px; text-align: left; font-family: inherit; color: inherit; background: none; cursor: pointer; transition: background-color 150ms var(--ease-out); }
|
||||
.jny-rowpick:hover { background: hsl(var(--secondary) / 0.55); }
|
||||
.jny-rowpick:active { transform: scale(0.99); }
|
||||
.jny-rowpick .jny-rleft { display: flex; flex-direction: column; min-width: 0; }
|
||||
.jny-rowpick .jny-rname { font-size: 14px; }
|
||||
.jny-rowpick .jny-rmeta { font-size: 11.5px; color: hsl(var(--muted-foreground)); margin-top: 1px; }
|
||||
.jny-rowpick .jny-rorg { font-size: 11.5px; color: hsl(var(--muted-foreground) / 0.75); font-variant-numeric: tabular-nums; white-space: nowrap; }
|
||||
.jny-rowpick.is-existing .jny-rname { color: hsl(var(--muted-foreground)); }
|
||||
.jny-rownote { font-size: 12px; color: hsl(var(--muted-foreground)); padding: 14px; }
|
||||
.jny-filter { max-width: 340px; }
|
||||
.jny-filter input { font-size: 17px; letter-spacing: 0.01em; }
|
||||
|
||||
/* ── done scene: profile card + notes ────────────────────────────── */
|
||||
.jny-card {
|
||||
margin: 22px auto 0; max-width: 380px; text-align: left;
|
||||
background: hsl(var(--card));
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 14px;
|
||||
padding: 16px 20px;
|
||||
box-shadow: 0 1px 2px hsl(0 0% 0% / 0.05), 0 16px 40px -24px hsl(0 0% 0% / 0.25);
|
||||
}
|
||||
.jny-card-eyebrow { font-size: 10px; letter-spacing: 0.08em; text-transform: uppercase; color: hsl(var(--muted-foreground)); }
|
||||
.jny-card-name { font-family: var(--font-display); font-size: 17px; margin-top: 2px; }
|
||||
.jny-card dl { margin: 12px 0 0; }
|
||||
.jny-card-row {
|
||||
display: flex; justify-content: space-between; gap: 18px;
|
||||
padding: 7px 0; border-top: 1px dashed hsl(var(--border) / 0.8);
|
||||
font-size: 12.5px;
|
||||
opacity: 0; transform: translateY(4px);
|
||||
transition: opacity 500ms var(--ease-out), transform 500ms var(--ease-out);
|
||||
}
|
||||
.jny-card-row.is-on { opacity: 1; transform: none; }
|
||||
.jny-card-row dt { color: hsl(var(--muted-foreground)); margin: 0; }
|
||||
.jny-card-row dd { margin: 0; text-align: right; font-variant-numeric: tabular-nums; }
|
||||
.jny-notes { margin: 20px auto 0; max-width: 430px; font-size: 12.5px; color: hsl(var(--muted-foreground)); line-height: 2; }
|
||||
.jny-notes div { opacity: 0; transform: translateY(4px); transition: opacity 500ms var(--ease-out), transform 500ms var(--ease-out); }
|
||||
.jny-notes div.is-on { opacity: 1; transform: none; }
|
||||
|
||||
/* ── dawn: progress as atmosphere (composes with OnboardingBackdrop) ─ */
|
||||
.jny-dawn {
|
||||
position: fixed; left: 0; right: 0; bottom: 0; height: 55vh;
|
||||
pointer-events: none; z-index: 0;
|
||||
background: radial-gradient(120% 95% at 50% 100%, hsl(36 60% 62% / 0.26), transparent 66%);
|
||||
opacity: var(--jny-dawn, 0);
|
||||
transition: opacity 1100ms var(--ease-out);
|
||||
}
|
||||
.dark .jny-dawn { background: radial-gradient(120% 95% at 50% 100%, hsl(30 45% 42% / 0.26), transparent 66%); }
|
||||
|
||||
/* ── compression for short windows: shrink, never scroll ─────────── */
|
||||
@media (max-height: 780px) {
|
||||
.jny-facts { margin-top: 10px; min-height: 36px; }
|
||||
.jny-qtitle { font-size: clamp(20px, 3vw, 26px); }
|
||||
.jny-qsub { margin-bottom: 16px; }
|
||||
.jny-qactions { margin-top: 20px; }
|
||||
.jny-card { margin-top: 14px; padding: 12px 18px; }
|
||||
.jny-card-row { padding: 5px 0; }
|
||||
.jny-notes { margin-top: 12px; line-height: 1.8; }
|
||||
.jny-rowlist { max-height: 36vh; margin-top: 12px; }
|
||||
.jny-mchips { margin-top: 12px; }
|
||||
.jny-balance { flex-basis: 110px; }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.jny *, .jny *::before, .jny *::after { animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; }
|
||||
}
|
||||
@@ -0,0 +1,501 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
initJourney,
|
||||
journeyReducer,
|
||||
stationOfStep,
|
||||
type JourneyAction,
|
||||
type JourneyState,
|
||||
} from '../reducer'
|
||||
import type { CompanyLookupResult } from '@/lib/company-lookup/types'
|
||||
|
||||
function lookup(overrides: Partial<CompanyLookupResult> = {}): CompanyLookupResult {
|
||||
return {
|
||||
companyName: 'Nordvik Bygg & Konsult AB',
|
||||
isCeased: false,
|
||||
address: { street: 'Storgatan 1', postalCode: '211 34', city: 'Malmö' },
|
||||
registration: { fTax: true, vat: true },
|
||||
bankAccounts: [],
|
||||
email: null,
|
||||
phone: null,
|
||||
sniCodes: [],
|
||||
fiscalYear: { startMonthDay: '01-01', endMonthDay: '12-31' },
|
||||
legalEntityType: 'AB',
|
||||
registrationDate: null,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function run(state: JourneyState, ...actions: JourneyAction[]): JourneyState {
|
||||
return actions.reduce(journeyReducer, state)
|
||||
}
|
||||
|
||||
/** Manual AB path up to the fiscal-year station. */
|
||||
function manualAbAtFy(): JourneyState {
|
||||
return run(
|
||||
initJourney(),
|
||||
{ type: 'ORG_SUBMITTED', orgNumber: '559999-9999' },
|
||||
{ type: 'LOOKUP_RESULT', outcome: { status: 'not_found' } },
|
||||
{ type: 'NOTFOUND_CONTINUE' },
|
||||
{ type: 'ENTITY_PICKED', entityType: 'aktiebolag' },
|
||||
{ type: 'NAME_SUBMITTED', name: 'Testbolaget AB' },
|
||||
{ type: 'ADDRESS_SUBMITTED', addressLine1: 'Gatan 1', postalCode: '123 45', city: 'Lund' },
|
||||
{ type: 'FSKATT_ANSWERED', fskatt: true },
|
||||
)
|
||||
}
|
||||
|
||||
describe('journeyReducer: AB found via lookup', () => {
|
||||
it('lands on the fiscal-year station with name/address/F-skatt as facts', () => {
|
||||
const s = run(
|
||||
initJourney(),
|
||||
{ type: 'ORG_SUBMITTED', orgNumber: '556677-8899' },
|
||||
{ type: 'LOOKUP_RESULT', outcome: { status: 'found', result: lookup() } },
|
||||
)
|
||||
expect(s.step).toBe('fy')
|
||||
expect(s.lookupRan).toBe(true)
|
||||
expect(s.settings).toMatchObject({
|
||||
org_number: '556677-8899',
|
||||
entity_type: 'aktiebolag',
|
||||
company_name: 'Nordvik Bygg & Konsult AB',
|
||||
address_line1: 'Storgatan 1',
|
||||
postal_code: '211 34',
|
||||
city: 'Malmö',
|
||||
f_skatt: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('skips the moms question only for a POSITIVE VAT registration', () => {
|
||||
const s = run(
|
||||
initJourney(),
|
||||
{ type: 'ORG_SUBMITTED', orgNumber: '556677-8899' },
|
||||
{ type: 'LOOKUP_RESULT', outcome: { status: 'found', result: lookup() } },
|
||||
{ type: 'FY_CALENDAR_CONFIRMED' },
|
||||
)
|
||||
expect(s.step).toBe('moms')
|
||||
expect(s.settings.vat_registered).toBe(true)
|
||||
expect(s.settings.vat_number).toBe('SE556677889901')
|
||||
})
|
||||
|
||||
it('collects the full wizard payload through to submit', () => {
|
||||
const s = run(
|
||||
initJourney(),
|
||||
{ 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' },
|
||||
)
|
||||
expect(s.submitting).toBe(true)
|
||||
expect(s.settings).toMatchObject({
|
||||
entity_type: 'aktiebolag',
|
||||
company_name: 'Nordvik Bygg & Konsult AB',
|
||||
org_number: '556677-8899',
|
||||
address_line1: 'Storgatan 1',
|
||||
postal_code: '211 34',
|
||||
city: 'Malmö',
|
||||
f_skatt: true,
|
||||
fiscal_year_start_month: 1,
|
||||
vat_registered: true,
|
||||
vat_number: 'SE556677889901',
|
||||
moms_period: 'quarterly',
|
||||
accounting_method: 'accrual',
|
||||
})
|
||||
const done = journeyReducer(s, { type: 'SUBMIT_SUCCEEDED' })
|
||||
expect(done.step).toBe('done')
|
||||
expect(stationOfStep(done.step)).toBe(4)
|
||||
})
|
||||
})
|
||||
|
||||
describe('journeyReducer: EF found via lookup', () => {
|
||||
const efLookup = lookup({
|
||||
companyName: 'Alice Nordin',
|
||||
legalEntityType: 'Enskild näringsidkare',
|
||||
registration: { fTax: true, vat: false },
|
||||
})
|
||||
|
||||
it('still asks the verksamhetsnamn question (name is a choice for EF)', () => {
|
||||
const s = run(
|
||||
initJourney(),
|
||||
{ type: 'ORG_SUBMITTED', orgNumber: '19850420-1234' },
|
||||
{ type: 'LOOKUP_RESULT', outcome: { status: 'found', result: efLookup } },
|
||||
)
|
||||
expect(s.step).toBe('name')
|
||||
expect(s.settings.entity_type).toBe('enskild_firma')
|
||||
expect(s.settings.company_name).toBe('Alice Nordin')
|
||||
})
|
||||
|
||||
it('skips address and F-skatt after the name (lookup facts), then asks moms: never defaults a negative VAT', () => {
|
||||
const s = run(
|
||||
initJourney(),
|
||||
{ type: 'ORG_SUBMITTED', orgNumber: '19850420-1234' },
|
||||
{ type: 'LOOKUP_RESULT', outcome: { status: 'found', result: efLookup } },
|
||||
{ type: 'NAME_SUBMITTED', name: 'Alice Nordin Design' },
|
||||
{ type: 'FY_CALENDAR_CONFIRMED' },
|
||||
)
|
||||
expect(s.settings.company_name).toBe('Alice Nordin Design')
|
||||
expect(s.settings.f_skatt).toBe(true)
|
||||
expect(s.step).toBe('momsyn')
|
||||
expect(s.settings.vat_registered).toBeUndefined()
|
||||
})
|
||||
|
||||
it('answers moms nej: no VAT number, no period, straight to method', () => {
|
||||
const s = run(
|
||||
initJourney(),
|
||||
{ type: 'ORG_SUBMITTED', orgNumber: '19850420-1234' },
|
||||
{ type: 'LOOKUP_RESULT', outcome: { status: 'found', result: efLookup } },
|
||||
{ type: 'NAME_SUBMITTED', name: 'Alice Nordin' },
|
||||
{ type: 'FY_CALENDAR_CONFIRMED' },
|
||||
{ type: 'VAT_ANSWERED', registered: false },
|
||||
)
|
||||
expect(s.step).toBe('method')
|
||||
expect(s.settings.vat_registered).toBe(false)
|
||||
expect(s.settings.vat_number).toBeNull()
|
||||
expect(s.settings.moms_period).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('journeyReducer: manual path (not found)', () => {
|
||||
it('walks form → name → address → F-skatt → fy', () => {
|
||||
let s = run(
|
||||
initJourney(),
|
||||
{ type: 'ORG_SUBMITTED', orgNumber: '559999-9999' },
|
||||
{ type: 'LOOKUP_RESULT', outcome: { status: 'not_found' } },
|
||||
)
|
||||
expect(s.step).toBe('notfound')
|
||||
s = journeyReducer(s, { type: 'NOTFOUND_CONTINUE' })
|
||||
expect(s.step).toBe('form')
|
||||
s = journeyReducer(s, { type: 'ENTITY_PICKED', entityType: 'aktiebolag' })
|
||||
expect(s.step).toBe('name')
|
||||
s = journeyReducer(s, { type: 'NAME_SUBMITTED', name: 'Testbolaget AB' })
|
||||
expect(s.step).toBe('address')
|
||||
s = journeyReducer(s, { type: 'ADDRESS_SUBMITTED', city: 'Lund' })
|
||||
expect(s.step).toBe('fskatt')
|
||||
s = journeyReducer(s, { type: 'FSKATT_ANSWERED', fskatt: false })
|
||||
expect(s.step).toBe('fy')
|
||||
expect(s.settings.f_skatt).toBe(false)
|
||||
expect(s.lookupRan).toBe(false)
|
||||
})
|
||||
|
||||
it('address can be skipped (empty submit) and is only asked once', () => {
|
||||
const s = run(
|
||||
initJourney(),
|
||||
{ type: 'ORG_SUBMITTED', orgNumber: '559999-9999' },
|
||||
{ type: 'LOOKUP_RESULT', outcome: { status: 'not_found' } },
|
||||
{ type: 'NOTFOUND_CONTINUE' },
|
||||
{ type: 'ENTITY_PICKED', entityType: 'aktiebolag' },
|
||||
{ type: 'NAME_SUBMITTED', name: 'Testbolaget AB' },
|
||||
{ type: 'ADDRESS_SUBMITTED' },
|
||||
)
|
||||
expect(s.step).toBe('fskatt')
|
||||
expect(s.settings.address_line1).toBeUndefined()
|
||||
})
|
||||
|
||||
it('notfound → edit clears the orgnr and returns to the question', () => {
|
||||
const s = run(
|
||||
initJourney(),
|
||||
{ type: 'ORG_SUBMITTED', orgNumber: '559999-9999' },
|
||||
{ type: 'LOOKUP_RESULT', outcome: { status: 'not_found' } },
|
||||
{ type: 'NOTFOUND_EDIT' },
|
||||
)
|
||||
expect(s.step).toBe('orgnr')
|
||||
expect(s.settings.org_number).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('journeyReducer: ceased company', () => {
|
||||
it('interjects the ceased step and can continue with facts', () => {
|
||||
let s = run(
|
||||
initJourney(),
|
||||
{ type: 'ORG_SUBMITTED', orgNumber: '556012-3456' },
|
||||
{ type: 'LOOKUP_RESULT', outcome: { status: 'found', result: lookup({ isCeased: true }) } },
|
||||
)
|
||||
expect(s.step).toBe('ceased')
|
||||
s = journeyReducer(s, { type: 'CEASED_CONTINUE' })
|
||||
expect(s.step).toBe('fy')
|
||||
expect(s.settings.company_name).toBe('Nordvik Bygg & Konsult AB')
|
||||
})
|
||||
|
||||
it('ceased → edit returns to the orgnr question', () => {
|
||||
const s = run(
|
||||
initJourney(),
|
||||
{ type: 'ORG_SUBMITTED', orgNumber: '556012-3456' },
|
||||
{ type: 'LOOKUP_RESULT', outcome: { status: 'found', result: lookup({ isCeased: true }) } },
|
||||
{ type: 'CEASED_EDIT' },
|
||||
)
|
||||
expect(s.step).toBe('orgnr')
|
||||
expect(s.settings.org_number).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('journeyReducer: BankID prefill', () => {
|
||||
const init = () =>
|
||||
initJourney({
|
||||
initialOrgNumber: '556677-8899',
|
||||
initialEntityType: 'aktiebolag',
|
||||
initialLegalName: 'Nordvik Bygg & Konsult AB',
|
||||
})
|
||||
|
||||
it('a successful lookup on the deep-linked orgnr yields the facts path', () => {
|
||||
const s = run(
|
||||
init(),
|
||||
{ type: 'ORG_SUBMITTED', orgNumber: '556677-8899' },
|
||||
{ type: 'LOOKUP_RESULT', outcome: { status: 'found', result: lookup() } },
|
||||
)
|
||||
expect(s.step).toBe('fy')
|
||||
expect(s.lookupRan).toBe(true)
|
||||
})
|
||||
|
||||
it('lookup failure degrades to questions: address and F-skatt are ASKED, VAT never defaulted', () => {
|
||||
let s = run(
|
||||
init(),
|
||||
{ type: 'ORG_SUBMITTED', orgNumber: '556677-8899' },
|
||||
{ type: 'LOOKUP_RESULT', outcome: { status: 'error' } },
|
||||
)
|
||||
// Name/entity from CompanyRoles survive as prefill; the rest is asked.
|
||||
expect(s.step).toBe('address')
|
||||
expect(s.lookupNote).toBe('error')
|
||||
expect(s.lookupRan).toBe(false)
|
||||
s = run(
|
||||
s,
|
||||
{ type: 'ADDRESS_SUBMITTED', city: 'Malmö' },
|
||||
{ type: 'FSKATT_ANSWERED', fskatt: true },
|
||||
{ type: 'FY_CALENDAR_CONFIRMED' },
|
||||
)
|
||||
expect(s.step).toBe('momsyn')
|
||||
expect(s.settings.vat_registered).toBeUndefined()
|
||||
})
|
||||
|
||||
it('a disabled lookup surface degrades silently (no advisory note)', () => {
|
||||
const s = run(
|
||||
init(),
|
||||
{ type: 'ORG_SUBMITTED', orgNumber: '556677-8899' },
|
||||
{ type: 'LOOKUP_RESULT', outcome: { status: 'disabled' } },
|
||||
)
|
||||
expect(s.step).toBe('address')
|
||||
expect(s.lookupNote).toBe('none')
|
||||
})
|
||||
|
||||
it('without any prefill a disabled lookup goes to the form question', () => {
|
||||
const s = run(
|
||||
initJourney(),
|
||||
{ type: 'ORG_SUBMITTED', orgNumber: '556677-8899' },
|
||||
{ type: 'LOOKUP_RESULT', outcome: { status: 'disabled' } },
|
||||
)
|
||||
expect(s.step).toBe('form')
|
||||
})
|
||||
})
|
||||
|
||||
describe('journeyReducer: fiscal-year branches', () => {
|
||||
it('first year: start + end dates, ongoing start month derived from the end', () => {
|
||||
const s = run(
|
||||
manualAbAtFy(),
|
||||
{ type: 'FY_FIRST_SELECTED' },
|
||||
{ type: 'FY_START_PICKED', date: '2026-03-14' },
|
||||
{ type: 'FY_END_PICKED', date: '2026-12-31' },
|
||||
)
|
||||
expect(s.settings).toMatchObject({
|
||||
is_first_fiscal_year: true,
|
||||
first_year_start: '2026-03-14',
|
||||
first_year_end: '2026-12-31',
|
||||
fiscal_year_start_month: 1,
|
||||
})
|
||||
expect(s.step).toBe('momsyn')
|
||||
})
|
||||
|
||||
it('first year ending mid-year points the ongoing year at the next month', () => {
|
||||
const s = run(
|
||||
manualAbAtFy(),
|
||||
{ type: 'FY_FIRST_SELECTED' },
|
||||
{ type: 'FY_START_PICKED', date: '2026-03-14' },
|
||||
{ type: 'FY_END_PICKED', date: '2027-06-30' },
|
||||
)
|
||||
expect(s.settings.fiscal_year_start_month).toBe(7)
|
||||
})
|
||||
|
||||
it('brutet år: end month picks the following start month', () => {
|
||||
const s = run(manualAbAtFy(), { type: 'FY_OTHER_SELECTED' }, { type: 'FY_END_MONTH_PICKED', endMonth: 6 })
|
||||
expect(s.settings.fiscal_year_start_month).toBe(7)
|
||||
expect(s.settings.is_first_fiscal_year).toBe(false)
|
||||
})
|
||||
|
||||
it('brutet år resolving to December is a plain calendar year', () => {
|
||||
const s = run(manualAbAtFy(), { type: 'FY_OTHER_SELECTED' }, { type: 'FY_END_MONTH_PICKED', endMonth: 12 })
|
||||
expect(s.settings.fiscal_year_start_month).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('journeyReducer: Back and station jumps', () => {
|
||||
it('Back restores each step to its ENTRY state (answers roll back)', () => {
|
||||
const atFy = manualAbAtFy()
|
||||
let s = journeyReducer(atFy, { type: 'BACK' })
|
||||
expect(s.step).toBe('fskatt')
|
||||
expect(s.settings.f_skatt).toBeUndefined()
|
||||
s = journeyReducer(s, { type: 'BACK' })
|
||||
expect(s.step).toBe('address')
|
||||
expect(s.settings.address_line1).toBeUndefined()
|
||||
expect(s.settings.city).toBeUndefined()
|
||||
s = journeyReducer(s, { type: 'BACK' })
|
||||
expect(s.step).toBe('name')
|
||||
expect(s.settings.company_name).toBeUndefined()
|
||||
s = journeyReducer(s, { type: 'BACK' })
|
||||
expect(s.step).toBe('form')
|
||||
expect(s.settings.entity_type).toBeUndefined()
|
||||
})
|
||||
|
||||
it('a station jump rewinds to the first step of that station', () => {
|
||||
const s = run(
|
||||
initJourney(),
|
||||
{ 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: 'STATION_JUMP', station: 1 },
|
||||
)
|
||||
expect(s.step).toBe('fy')
|
||||
expect(s.settings.moms_period).toBeUndefined()
|
||||
expect(s.settings.fiscal_year_start_month).toBeUndefined()
|
||||
// The lookup facts from station 0 survive.
|
||||
expect(s.settings.company_name).toBe('Nordvik Bygg & Konsult AB')
|
||||
})
|
||||
|
||||
it('jumping to the Företaget station lands on the orgnr question', () => {
|
||||
const s = run(
|
||||
manualAbAtFy(),
|
||||
{ type: 'FY_CALENDAR_CONFIRMED' },
|
||||
{ type: 'STATION_JUMP', station: 0 },
|
||||
)
|
||||
expect(s.step).toBe('orgnr')
|
||||
expect(s.settings.company_name).toBeUndefined()
|
||||
})
|
||||
|
||||
it('ignores jumps to the current or a later station', () => {
|
||||
const s = manualAbAtFy()
|
||||
expect(journeyReducer(s, { type: 'STATION_JUMP', station: 1 })).toBe(s)
|
||||
expect(journeyReducer(s, { type: 'STATION_JUMP', station: 3 })).toBe(s)
|
||||
})
|
||||
})
|
||||
|
||||
describe('journeyReducer: entity change wipe', () => {
|
||||
it('a genuine entity change wipes org/name and all downstream answers', () => {
|
||||
let s = run(manualAbAtFy(), { type: 'FY_CALENDAR_CONFIRMED' }, { type: 'VAT_ANSWERED', registered: false })
|
||||
expect(s.step).toBe('method')
|
||||
// Defensive guard: an entity change with answers on file (however the UI
|
||||
// reaches it) resets the flow to the orgnr question and wipes downstream.
|
||||
s = journeyReducer(s, { type: 'ENTITY_PICKED', entityType: 'enskild_firma' })
|
||||
expect(s.step).toBe('orgnr')
|
||||
expect(s.settings.entity_type).toBe('enskild_firma')
|
||||
expect(s.settings.org_number).toBeUndefined()
|
||||
expect(s.settings.company_name).toBeUndefined()
|
||||
expect(s.settings.vat_registered).toBeUndefined()
|
||||
expect(s.settings.moms_period).toBeUndefined()
|
||||
expect(s.settings.accounting_method).toBeUndefined()
|
||||
})
|
||||
|
||||
it('a station jump to Företaget already rolls entity back, so re-picking is first-time', () => {
|
||||
let s = run(manualAbAtFy(), { type: 'FY_CALENDAR_CONFIRMED' }, { type: 'VAT_ANSWERED', registered: false })
|
||||
s = run(
|
||||
s,
|
||||
{ type: 'STATION_JUMP', station: 0 },
|
||||
{ type: 'ORG_SUBMITTED', orgNumber: '559999-9999' },
|
||||
{ type: 'LOOKUP_RESULT', outcome: { status: 'not_found' } },
|
||||
{ type: 'NOTFOUND_CONTINUE' },
|
||||
{ type: 'ENTITY_PICKED', entityType: 'enskild_firma' },
|
||||
)
|
||||
// Entry snapshots already wiped the old answers at the jump; the fresh
|
||||
// pick proceeds forward (no second wipe needed).
|
||||
expect(s.step).toBe('name')
|
||||
expect(s.settings.entity_type).toBe('enskild_firma')
|
||||
expect(s.settings.vat_registered).toBeUndefined()
|
||||
expect(s.settings.accounting_method).toBeUndefined()
|
||||
})
|
||||
|
||||
it('first-time entity selection keeps a deep-link prefill', () => {
|
||||
const s = run(
|
||||
initJourney({ initialOrgNumber: '556677-8899', initialLegalName: 'Nordvik AB' }),
|
||||
{ type: 'ORG_SUBMITTED', orgNumber: '556677-8899' },
|
||||
{ type: 'LOOKUP_RESULT', outcome: { status: 'error' } },
|
||||
{ type: 'ENTITY_PICKED', entityType: 'aktiebolag' },
|
||||
)
|
||||
expect(s.settings.org_number).toBe('556677-8899')
|
||||
expect(s.settings.company_name).toBe('Nordvik AB')
|
||||
})
|
||||
})
|
||||
|
||||
describe('journeyReducer: server errors', () => {
|
||||
function submitted(): JourneyState {
|
||||
return run(
|
||||
initJourney(),
|
||||
{ type: 'ORG_SUBMITTED', orgNumber: '556677-8899' },
|
||||
{ type: 'LOOKUP_RESULT', outcome: { status: 'found', result: lookup() } },
|
||||
{ type: 'FY_CALENDAR_CONFIRMED' },
|
||||
{ type: 'MOMS_PERIOD_PICKED', period: 'monthly' },
|
||||
{ type: 'METHOD_PICKED', method: 'cash' },
|
||||
)
|
||||
}
|
||||
|
||||
it('org_number_invalid travels back to the orgnr question, keeping answers', () => {
|
||||
const s = journeyReducer(submitted(), { type: 'SUBMIT_FAILED', code: 'org_number_invalid' })
|
||||
expect(s.step).toBe('orgnr')
|
||||
expect(s.serverError).toBe('org_number_invalid')
|
||||
expect(s.submitting).toBe(false)
|
||||
expect(s.lookupRan).toBe(false)
|
||||
expect(s.settings.accounting_method).toBe('cash')
|
||||
})
|
||||
|
||||
it('period_invalid returns to the fiscal-year station', () => {
|
||||
const s = journeyReducer(submitted(), { type: 'SUBMIT_FAILED', code: 'period_invalid' })
|
||||
expect(s.step).toBe('fy')
|
||||
expect(s.serverError).toBe('period_invalid')
|
||||
})
|
||||
|
||||
it('generic failure stays on method with a retry state', () => {
|
||||
const s = journeyReducer(submitted(), { type: 'SUBMIT_FAILED', code: 'generic' })
|
||||
expect(s.step).toBe('method')
|
||||
expect(s.serverError).toBe('generic')
|
||||
expect(s.submitting).toBe(false)
|
||||
})
|
||||
|
||||
it('any new answer clears the server error', () => {
|
||||
const failed = journeyReducer(submitted(), { type: 'SUBMIT_FAILED', code: 'org_number_invalid' })
|
||||
const s = run(failed, { type: 'ORG_SUBMITTED', orgNumber: '556677-8899' })
|
||||
expect(s.serverError).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('journeyReducer: robustness', () => {
|
||||
it('ignores a LOOKUP_RESULT when no lookup is pending', () => {
|
||||
const s = initJourney()
|
||||
expect(journeyReducer(s, { type: 'LOOKUP_RESULT', outcome: { status: 'not_found' } })).toBe(s)
|
||||
})
|
||||
|
||||
it('ignores an aborted lookup (stays on orgnr, pending cleared)', () => {
|
||||
const s = run(
|
||||
initJourney(),
|
||||
{ type: 'ORG_SUBMITTED', orgNumber: '556677-8899' },
|
||||
{ type: 'LOOKUP_RESULT', outcome: { status: 'aborted' } },
|
||||
)
|
||||
expect(s.step).toBe('orgnr')
|
||||
expect(s.lookupPending).toBe(false)
|
||||
})
|
||||
|
||||
it('ignores Back while submitting and with empty history', () => {
|
||||
const fresh = initJourney()
|
||||
expect(journeyReducer(fresh, { type: 'BACK' })).toBe(fresh)
|
||||
})
|
||||
|
||||
it('an unmappable entity type from the lookup falls through to the form question', () => {
|
||||
const s = run(
|
||||
initJourney(),
|
||||
{ type: 'ORG_SUBMITTED', orgNumber: '769612-3456' },
|
||||
{
|
||||
type: 'LOOKUP_RESULT',
|
||||
outcome: {
|
||||
status: 'found',
|
||||
result: lookup({ companyName: 'Brf Sjöutsikten', legalEntityType: 'BRF' }),
|
||||
},
|
||||
},
|
||||
)
|
||||
expect(s.step).toBe('form')
|
||||
expect(s.settings.company_name).toBe('Brf Sjöutsikten')
|
||||
expect(s.lookupRan).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,551 @@
|
||||
import type { CompanySettings, EntityType, MomsPeriod } from '@/types'
|
||||
import type { CompanyLookupResult } from '@/lib/company-lookup/types'
|
||||
import type { CompanyLookupOutcome } from '@/lib/company-lookup/fetch-company-lookup'
|
||||
import { mapEntityType } from '@/lib/company-lookup/entity-type-map'
|
||||
import { deriveSwedishVatNumber } from '@/lib/vat/vat-number'
|
||||
|
||||
/**
|
||||
* Pure state machine for the journey onboarding
|
||||
* (dev_docs/onboarding_migration_plan.md). The component renders `step`,
|
||||
* dispatches actions, and performs the side effects (the single TIC lookup
|
||||
* via fetchCompanyLookup, the createCompanyFromOnboarding call); the reducer
|
||||
* owns every transition and every settings write.
|
||||
*
|
||||
* Invariants encoded here:
|
||||
* - `settings` accumulates the exact CompanySettings partial today's wizard
|
||||
* sends to createCompanyFromOnboarding: nothing less.
|
||||
* - `lookupRan` is true only when TIC answered with data for the CURRENT
|
||||
* orgnr. Facts (name/address/F-skatt/moms/räkenskapsår) may be presented
|
||||
* as facts only then; otherwise they are asked as questions. BankID
|
||||
* prefill without a successful lookup is the degraded ask-questions path.
|
||||
* - `vat_registered` is never silently defaulted: it is either lookup data
|
||||
* (registration.vat === true) or an explicit answer (ML 17 kap 24 §).
|
||||
* - History stores each step's ENTRY snapshot, so Back rolls both answers
|
||||
* and stations to how they were when the step began.
|
||||
*/
|
||||
|
||||
export type JourneyStep =
|
||||
| 'orgnr'
|
||||
| 'notfound'
|
||||
| 'ceased'
|
||||
| 'form'
|
||||
| 'name'
|
||||
| 'address'
|
||||
| 'fskatt'
|
||||
| 'fy'
|
||||
| 'fymonth'
|
||||
| 'fystart'
|
||||
| 'fyend'
|
||||
| 'momsyn'
|
||||
| 'moms'
|
||||
| 'method'
|
||||
| 'done'
|
||||
|
||||
export type JourneyStation = 0 | 1 | 2 | 3 | 4
|
||||
|
||||
const STATION_OF: Record<JourneyStep, JourneyStation> = {
|
||||
orgnr: 0,
|
||||
notfound: 0,
|
||||
ceased: 0,
|
||||
form: 0,
|
||||
name: 0,
|
||||
address: 0,
|
||||
fskatt: 0,
|
||||
fy: 1,
|
||||
fymonth: 1,
|
||||
fystart: 1,
|
||||
fyend: 1,
|
||||
momsyn: 2,
|
||||
moms: 2,
|
||||
method: 3,
|
||||
done: 4,
|
||||
}
|
||||
|
||||
export function stationOfStep(step: JourneyStep): JourneyStation {
|
||||
return STATION_OF[step]
|
||||
}
|
||||
|
||||
export type JourneyServerError =
|
||||
| 'org_number_invalid'
|
||||
| 'period_invalid'
|
||||
| 'generic'
|
||||
| null
|
||||
|
||||
/** The slice of state a step's entry snapshot preserves for Back. */
|
||||
interface JourneySnapshot {
|
||||
step: JourneyStep
|
||||
settings: Partial<CompanySettings>
|
||||
ticLookup: CompanyLookupResult | null
|
||||
lookupRan: boolean
|
||||
lookupNote: 'none' | 'error'
|
||||
addressAsked: boolean
|
||||
/** EF only: the verksamhetsnamn question was explicitly answered. */
|
||||
nameConfirmedForEf: boolean
|
||||
}
|
||||
|
||||
export interface JourneyState extends JourneySnapshot {
|
||||
/** Entry snapshot of the CURRENT step (what Back from a later step restores). */
|
||||
entry: JourneySnapshot
|
||||
history: JourneySnapshot[]
|
||||
/** Component fires the lookup while this is true; reducer set on ORG_SUBMITTED. */
|
||||
lookupPending: boolean
|
||||
/** BankID CompanyRoles prefill present (name/entity trusted without lookup). */
|
||||
viaPrefill: boolean
|
||||
mode: 'first' | 'add'
|
||||
submitting: boolean
|
||||
serverError: JourneyServerError
|
||||
}
|
||||
|
||||
export interface JourneyInit {
|
||||
mode?: 'first' | 'add'
|
||||
/** ?org_number= deep link. The component auto-submits it on mount, which
|
||||
* triggers the same single lookup as manual entry (2026-07-24 addendum:
|
||||
* no preverified suppression in the journey). */
|
||||
initialOrgNumber?: string
|
||||
initialEntityType?: EntityType
|
||||
initialLegalName?: string
|
||||
}
|
||||
|
||||
export type JourneyAction =
|
||||
| { type: 'ORG_SUBMITTED'; orgNumber: string }
|
||||
| { type: 'LOOKUP_RESULT'; outcome: CompanyLookupOutcome }
|
||||
| { type: 'NOTFOUND_CONTINUE' }
|
||||
| { type: 'NOTFOUND_EDIT' }
|
||||
| { type: 'CEASED_CONTINUE' }
|
||||
| { type: 'CEASED_EDIT' }
|
||||
| { type: 'ENTITY_PICKED'; entityType: EntityType }
|
||||
| { type: 'NAME_SUBMITTED'; name: string }
|
||||
| { type: 'ADDRESS_SUBMITTED'; addressLine1?: string; postalCode?: string; city?: string }
|
||||
| { type: 'FSKATT_ANSWERED'; fskatt: boolean }
|
||||
| { type: 'FY_CALENDAR_CONFIRMED' }
|
||||
| { type: 'FY_OTHER_SELECTED' }
|
||||
| { type: 'FY_FIRST_SELECTED' }
|
||||
| { type: 'FY_END_MONTH_PICKED'; endMonth: number }
|
||||
| { type: 'FY_START_PICKED'; date: string }
|
||||
| { type: 'FY_END_PICKED'; date: string }
|
||||
| { type: 'VAT_ANSWERED'; registered: boolean }
|
||||
| { type: 'MOMS_PERIOD_PICKED'; period: MomsPeriod }
|
||||
| { type: 'METHOD_PICKED'; method: 'accrual' | 'cash' }
|
||||
| { type: 'SUBMIT_SUCCEEDED' }
|
||||
| { type: 'SUBMIT_FAILED'; code: 'org_number_invalid' | 'period_invalid' | 'generic' }
|
||||
| { type: 'BACK' }
|
||||
| { type: 'STATION_JUMP'; station: 0 | 1 | 2 | 3 }
|
||||
|
||||
function snapshotOf(s: JourneySnapshot): JourneySnapshot {
|
||||
return {
|
||||
step: s.step,
|
||||
settings: s.settings,
|
||||
ticLookup: s.ticLookup,
|
||||
lookupRan: s.lookupRan,
|
||||
lookupNote: s.lookupNote,
|
||||
addressAsked: s.addressAsked,
|
||||
nameConfirmedForEf: s.nameConfirmedForEf,
|
||||
}
|
||||
}
|
||||
|
||||
export function initJourney(init: JourneyInit = {}): JourneyState {
|
||||
const settings: Partial<CompanySettings> = {}
|
||||
if (init.initialOrgNumber) settings.org_number = init.initialOrgNumber
|
||||
if (init.initialEntityType) settings.entity_type = init.initialEntityType
|
||||
if (init.initialLegalName) settings.company_name = init.initialLegalName
|
||||
const base: JourneySnapshot = {
|
||||
step: 'orgnr',
|
||||
settings,
|
||||
ticLookup: null,
|
||||
lookupRan: false,
|
||||
lookupNote: 'none',
|
||||
addressAsked: false,
|
||||
nameConfirmedForEf: false,
|
||||
}
|
||||
return {
|
||||
...base,
|
||||
entry: snapshotOf(base),
|
||||
history: [],
|
||||
lookupPending: false,
|
||||
viaPrefill: Boolean(init.initialOrgNumber && (init.initialEntityType || init.initialLegalName)),
|
||||
mode: init.mode ?? 'first',
|
||||
submitting: false,
|
||||
serverError: null,
|
||||
}
|
||||
}
|
||||
|
||||
/** Transition to `next`, pushing the current step's entry snapshot.
|
||||
* `patch` wins over the defaults (a transition may carry a serverError). */
|
||||
function go(state: JourneyState, next: JourneyStep, patch?: Partial<JourneyState>): JourneyState {
|
||||
const moved: JourneyState = {
|
||||
...state,
|
||||
lookupPending: false,
|
||||
serverError: null,
|
||||
...patch,
|
||||
step: next,
|
||||
history: [...state.history, state.entry],
|
||||
}
|
||||
return { ...moved, entry: snapshotOf(moved) }
|
||||
}
|
||||
|
||||
/** Update within the current step (no history push, entry unchanged). */
|
||||
function stay(state: JourneyState, patch: Partial<JourneyState>): JourneyState {
|
||||
return { ...state, ...patch }
|
||||
}
|
||||
|
||||
/**
|
||||
* The Företaget station asks only what is still unknown, then hands over to
|
||||
* the fiscal-year station. Order: name → address → F-skatt.
|
||||
* - AB with a known company_name (lookup or BankID roles) skips the name
|
||||
* question; EF always confirms the verksamhetsnamn (it defaults to the
|
||||
* person's name but is freely choosable, same as the wizard).
|
||||
* - Address is asked only when the lookup did not provide one.
|
||||
* - F-skatt is asked whenever it is not lookup data.
|
||||
*/
|
||||
function nextCompanyStep(state: JourneyState): JourneyStep {
|
||||
const s = state.settings
|
||||
const nameKnown =
|
||||
s.entity_type === 'aktiebolag'
|
||||
? Boolean(s.company_name)
|
||||
: Boolean(s.company_name) && state.nameConfirmedForEf === true
|
||||
if (!nameKnown) return 'name'
|
||||
if (!state.lookupRan && !state.addressAsked) return 'address'
|
||||
if (s.f_skatt === undefined) return 'fskatt'
|
||||
return 'fy'
|
||||
}
|
||||
|
||||
/** After the fiscal-year station: skip the moms question only when the
|
||||
* lookup POSITIVELY says the company is VAT registered. A negative or
|
||||
* missing registration is always asked (never defaulted). */
|
||||
function afterFiscalYear(state: JourneyState): JourneyState {
|
||||
if (state.lookupRan && state.ticLookup?.registration.vat === true) {
|
||||
const settings = {
|
||||
...state.settings,
|
||||
vat_registered: true,
|
||||
vat_number: deriveSwedishVatNumber(state.settings.org_number),
|
||||
}
|
||||
return go(stay(state, { settings }), 'moms')
|
||||
}
|
||||
return go(state, 'momsyn')
|
||||
}
|
||||
|
||||
/** Downstream answers invalidated by an entity-type change. */
|
||||
function wipeDownstream(settings: Partial<CompanySettings>): Partial<CompanySettings> {
|
||||
const next = { ...settings }
|
||||
delete next.fiscal_year_start_month
|
||||
delete next.is_first_fiscal_year
|
||||
delete next.first_year_start
|
||||
delete next.first_year_end
|
||||
delete next.vat_registered
|
||||
delete next.vat_number
|
||||
delete next.moms_period
|
||||
delete next.accounting_method
|
||||
return next
|
||||
}
|
||||
|
||||
export function journeyReducer(state: JourneyState, action: JourneyAction): JourneyState {
|
||||
switch (action.type) {
|
||||
case 'ORG_SUBMITTED': {
|
||||
if (state.submitting) return state
|
||||
// Fresh orgnr invalidates any previous lookup facts.
|
||||
return stay(state, {
|
||||
settings: { ...state.settings, org_number: action.orgNumber },
|
||||
ticLookup: null,
|
||||
lookupRan: false,
|
||||
lookupNote: 'none',
|
||||
lookupPending: true,
|
||||
serverError: null,
|
||||
})
|
||||
}
|
||||
|
||||
case 'LOOKUP_RESULT': {
|
||||
if (!state.lookupPending) return state
|
||||
const cleared = stay(state, { lookupPending: false })
|
||||
const outcome = action.outcome
|
||||
|
||||
if (outcome.status === 'aborted') return cleared
|
||||
|
||||
if (outcome.status === 'found') {
|
||||
const lookup = outcome.result
|
||||
const mapped = mapEntityType(lookup.legalEntityType)
|
||||
const settings: Partial<CompanySettings> = {
|
||||
...state.settings,
|
||||
entity_type: mapped ?? state.settings.entity_type,
|
||||
company_name: lookup.companyName || state.settings.company_name,
|
||||
address_line1: lookup.address?.street ?? state.settings.address_line1,
|
||||
postal_code: lookup.address?.postalCode ?? state.settings.postal_code,
|
||||
city: lookup.address?.city ?? state.settings.city,
|
||||
f_skatt: lookup.registration.fTax,
|
||||
}
|
||||
const enriched = stay(cleared, {
|
||||
settings,
|
||||
ticLookup: lookup,
|
||||
lookupRan: true,
|
||||
lookupNote: 'none' as const,
|
||||
})
|
||||
if (lookup.isCeased) return go(enriched, 'ceased')
|
||||
if (!settings.entity_type) return go(enriched, 'form')
|
||||
return go(enriched, nextCompanyStep(enriched))
|
||||
}
|
||||
|
||||
if (outcome.status === 'not_found') {
|
||||
return go(cleared, 'notfound')
|
||||
}
|
||||
|
||||
// disabled: silent manual path. error: manual path + advisory note.
|
||||
const noted = stay(cleared, {
|
||||
lookupNote: outcome.status === 'error' ? ('error' as const) : ('none' as const),
|
||||
})
|
||||
if (noted.settings.entity_type) {
|
||||
// BankID prefill (or re-run after entity known): degraded ask-
|
||||
// questions path; entity/name from CompanyRoles survive as prefill.
|
||||
return go(noted, nextCompanyStep(noted))
|
||||
}
|
||||
return go(noted, 'form')
|
||||
}
|
||||
|
||||
case 'NOTFOUND_CONTINUE': {
|
||||
if (state.settings.entity_type) return go(state, nextCompanyStep(state))
|
||||
return go(state, 'form')
|
||||
}
|
||||
|
||||
case 'NOTFOUND_EDIT':
|
||||
case 'CEASED_EDIT': {
|
||||
// Back to the orgnr question; the fresh submit re-runs the single lookup.
|
||||
return go(state, 'orgnr', {
|
||||
settings: { ...state.settings, org_number: undefined },
|
||||
ticLookup: null,
|
||||
lookupRan: false,
|
||||
lookupNote: 'none',
|
||||
})
|
||||
}
|
||||
|
||||
case 'CEASED_CONTINUE': {
|
||||
// Proceed with the (ceased) lookup facts: same as wizard, which lets
|
||||
// the user continue after the inline warning.
|
||||
if (!state.settings.entity_type) return go(state, 'form')
|
||||
return go(state, nextCompanyStep(state))
|
||||
}
|
||||
|
||||
case 'ENTITY_PICKED': {
|
||||
const prev = state.settings.entity_type
|
||||
let settings: Partial<CompanySettings> = { ...state.settings, entity_type: action.entityType }
|
||||
let next = state
|
||||
if (prev && prev !== action.entityType) {
|
||||
// Same guard as the wizard's step-1 wipe, but broader per the plan:
|
||||
// a changed entity invalidates org/name and every downstream answer.
|
||||
settings = wipeDownstream({
|
||||
...settings,
|
||||
org_number: undefined,
|
||||
company_name: undefined,
|
||||
})
|
||||
next = stay(state, { ticLookup: null, lookupRan: false, nameConfirmedForEf: false })
|
||||
return go(stay(next, { settings }), 'orgnr')
|
||||
}
|
||||
return go(stay(next, { settings }), nextCompanyStep(stay(next, { settings })))
|
||||
}
|
||||
|
||||
case 'NAME_SUBMITTED': {
|
||||
const trimmed = action.name.trim()
|
||||
if (!trimmed) return state
|
||||
const patched = stay(state, {
|
||||
settings: { ...state.settings, company_name: trimmed },
|
||||
nameConfirmedForEf: true,
|
||||
})
|
||||
return go(patched, nextCompanyStep(patched))
|
||||
}
|
||||
|
||||
case 'ADDRESS_SUBMITTED': {
|
||||
const patched = stay(state, {
|
||||
settings: {
|
||||
...state.settings,
|
||||
address_line1: action.addressLine1 || state.settings.address_line1,
|
||||
postal_code: action.postalCode || state.settings.postal_code,
|
||||
city: action.city || state.settings.city,
|
||||
},
|
||||
addressAsked: true,
|
||||
})
|
||||
return go(patched, nextCompanyStep(patched))
|
||||
}
|
||||
|
||||
case 'FSKATT_ANSWERED': {
|
||||
const patched = stay(state, {
|
||||
settings: { ...state.settings, f_skatt: action.fskatt },
|
||||
})
|
||||
return go(patched, nextCompanyStep(patched))
|
||||
}
|
||||
|
||||
case 'FY_CALENDAR_CONFIRMED': {
|
||||
const patched = stay(state, {
|
||||
settings: {
|
||||
...state.settings,
|
||||
fiscal_year_start_month: 1,
|
||||
is_first_fiscal_year: false,
|
||||
first_year_start: undefined,
|
||||
first_year_end: undefined,
|
||||
},
|
||||
})
|
||||
return afterFiscalYear(patched)
|
||||
}
|
||||
|
||||
case 'FY_OTHER_SELECTED':
|
||||
return go(state, 'fymonth')
|
||||
|
||||
case 'FY_FIRST_SELECTED':
|
||||
return go(state, 'fystart')
|
||||
|
||||
case 'FY_END_MONTH_PICKED': {
|
||||
const m = action.endMonth
|
||||
if (!Number.isInteger(m) || m < 1 || m > 12) return state
|
||||
const patched = stay(state, {
|
||||
settings: {
|
||||
...state.settings,
|
||||
fiscal_year_start_month: m === 12 ? 1 : m + 1,
|
||||
is_first_fiscal_year: false,
|
||||
first_year_start: undefined,
|
||||
first_year_end: undefined,
|
||||
},
|
||||
})
|
||||
return afterFiscalYear(patched)
|
||||
}
|
||||
|
||||
case 'FY_START_PICKED': {
|
||||
const patched = stay(state, {
|
||||
settings: {
|
||||
...state.settings,
|
||||
is_first_fiscal_year: true,
|
||||
first_year_start: action.date,
|
||||
},
|
||||
})
|
||||
return go(patched, 'fyend')
|
||||
}
|
||||
|
||||
case 'FY_END_PICKED': {
|
||||
const endMonth = Number(action.date.split('-')[1])
|
||||
const patched = stay(state, {
|
||||
settings: {
|
||||
...state.settings,
|
||||
is_first_fiscal_year: true,
|
||||
first_year_end: action.date,
|
||||
// The ongoing fiscal year starts the month after the first year
|
||||
// ends: same derivation as the wizard's Step 3.
|
||||
fiscal_year_start_month:
|
||||
Number.isInteger(endMonth) && endMonth >= 1 && endMonth <= 12
|
||||
? endMonth === 12
|
||||
? 1
|
||||
: endMonth + 1
|
||||
: 1,
|
||||
},
|
||||
})
|
||||
return afterFiscalYear(patched)
|
||||
}
|
||||
|
||||
case 'VAT_ANSWERED': {
|
||||
if (action.registered) {
|
||||
const patched = stay(state, {
|
||||
settings: {
|
||||
...state.settings,
|
||||
vat_registered: true,
|
||||
vat_number: deriveSwedishVatNumber(state.settings.org_number),
|
||||
},
|
||||
})
|
||||
return go(patched, 'moms')
|
||||
}
|
||||
const patched = stay(state, {
|
||||
settings: {
|
||||
...state.settings,
|
||||
vat_registered: false,
|
||||
vat_number: null,
|
||||
moms_period: null,
|
||||
},
|
||||
})
|
||||
return go(patched, 'method')
|
||||
}
|
||||
|
||||
case 'MOMS_PERIOD_PICKED': {
|
||||
const patched = stay(state, {
|
||||
settings: { ...state.settings, moms_period: action.period },
|
||||
})
|
||||
return go(patched, 'method')
|
||||
}
|
||||
|
||||
case 'METHOD_PICKED': {
|
||||
return stay(state, {
|
||||
settings: { ...state.settings, accounting_method: action.method },
|
||||
submitting: true,
|
||||
serverError: null,
|
||||
})
|
||||
}
|
||||
|
||||
case 'SUBMIT_SUCCEEDED':
|
||||
return go(stay(state, { submitting: false }), 'done')
|
||||
|
||||
case 'SUBMIT_FAILED': {
|
||||
const cleared = stay(state, { submitting: false })
|
||||
if (action.code === 'org_number_invalid') {
|
||||
// The server rejected the orgnr: travel back to the Företaget
|
||||
// station. Answers are kept; the fresh orgnr re-runs the lookup and
|
||||
// the flow walks forward again.
|
||||
return go(cleared, 'orgnr', {
|
||||
ticLookup: null,
|
||||
lookupRan: false,
|
||||
serverError: 'org_number_invalid',
|
||||
})
|
||||
}
|
||||
if (action.code === 'period_invalid') {
|
||||
return go(cleared, 'fy', { serverError: 'period_invalid' })
|
||||
}
|
||||
return stay(cleared, { serverError: 'generic' })
|
||||
}
|
||||
|
||||
case 'BACK': {
|
||||
if (state.submitting || state.history.length === 0) return state
|
||||
const history = [...state.history]
|
||||
const snap = history.pop() as JourneySnapshot
|
||||
return {
|
||||
...state,
|
||||
...snap,
|
||||
entry: snap,
|
||||
history,
|
||||
lookupPending: false,
|
||||
submitting: false,
|
||||
serverError: null,
|
||||
}
|
||||
}
|
||||
|
||||
case 'STATION_JUMP': {
|
||||
if (state.submitting) return state
|
||||
const target = action.station
|
||||
if (stationOfStep(state.step) <= target) return state
|
||||
const history = [...state.history]
|
||||
let snap: JourneySnapshot | null = null
|
||||
while (history.length > 0) {
|
||||
const top = history[history.length - 1]
|
||||
const st = stationOfStep(top.step)
|
||||
if (st > target) {
|
||||
history.pop()
|
||||
continue
|
||||
}
|
||||
if (st === target) {
|
||||
snap = history.pop() as JourneySnapshot
|
||||
// Rewind to the station's FIRST step, not its last.
|
||||
while (
|
||||
history.length > 0 &&
|
||||
stationOfStep(history[history.length - 1].step) === target
|
||||
) {
|
||||
snap = history.pop() as JourneySnapshot
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
if (!snap) return state
|
||||
return {
|
||||
...state,
|
||||
...snap,
|
||||
entry: snap,
|
||||
history,
|
||||
lookupPending: false,
|
||||
submitting: false,
|
||||
serverError: null,
|
||||
}
|
||||
}
|
||||
|
||||
default:
|
||||
return state
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user