b771c1f923
* 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>
75 lines
2.5 KiB
TypeScript
75 lines
2.5 KiB
TypeScript
'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>
|
|
)
|
|
}
|