Files
accounted/components/onboarding/journey/OnboardingJourney.tsx
T
MattssonandClaude Fable 5.1 fc2d78a7c4 feat(onboarding): the orgnr step suggests companies as you type (SCB search, TIC on the pick) (#2452)
* feat(onboarding): the orgnr step suggests companies as you type, SCB search, TIC on the pick

Most people do not know their organisationsnummer. They left the
onboarding for allabolag, searched their company name there, copied the
number and pasted it back. #2421 let the field take a name, but only on
Enter and behind a screen that still said "organisationsnummer", so the
detour stayed. Now the field suggests companies while a name is typed
(name, orgnr or "Enskild firma", city; arrow keys or click to pick), the
pick fills the company like a typed orgnr, and the screen says "Vilket
företag är det?" with "Företagsnamn eller organisationsnummer" as the
placeholder.

Why the problem occurred: the one identifier the step asked for is the one
the user is least likely to remember, and the free-text path added in
#2421 was invisible (copy unchanged) and had to be guessed (Enter only),
because the only search index behind it was TIC, whose Lens budget cannot
take a call per keystroke.

What was removed or simplified: nothing is stored and no new state model:
a picked suggestion is an ORG_SUBMITTED with prefill, so the existing
LOOKUP_RESULT transitions (found, not found, disabled, error) decide the
step exactly as for a typed number. SCB's name search already existed for
the parties picker; it gained one option (sole traders) instead of a
second client. No rate limiting anywhere, per the founder.

Why this shape: SCB's företagsregister is free and already configured for
the parties picker, so search-as-you-type costs nothing while typing; TIC
runs once, on the pick, as it always did on Enter. TIC per keystroke was
rejected (3000/month). SCB alone was rejected for the pick because it
knows no F-skatt, VAT registration or fiscal year. The Enter path and the
chip row from #2421 stay as the fallback when no row is picked. Sole
traders are offered (they are half the users) but their row names the
form and never prints the personnummer, and the field shows the company
name after a pick for the same reason.

Changes:
- app/api/company/search: GET ?q= over the SCB client with sole traders
  included, top 6 rows plus a truncated flag; requireAuth() (no company
  yet), 400 for short or numeric q, 503 without SCB credentials, 502 when
  SCB does not answer.
- lib/parties/scb/client.ts: searchByName(query, { includeSoleTraders }),
  legalFormCode on every candidate; the parties picker is unchanged.
- lib/company-lookup: CompanySuggestion, COMPANY_SUGGEST_MAX,
  fetchCompanySuggestions (503 is disabled, everything else error, never
  throws), toCompanySuggestion (SCB legal form 49/10/61 into the TIC
  vocabulary mapSetupEntityType reads).
- lib/onboarding-journey/reducer.ts: SUGGESTION_PICKED (orgnr, name and
  form as prefill, lookupPending; lookupRan stays false until TIC answers).
- components/onboarding/journey: 300 ms debounced SCB search with abort of
  the superseded request, listbox under the field (combobox ARIA, arrow
  keys, Escape, Enter picks the highlighted row, otherwise the Enter path),
  copy switches with companySearchEnabled or ticEnabled; both journey
  pages pass isScbConfigured().
- messages sv+en: five strings.

Tests: route (401, 400 short, 400 missing, 400 numeric, 503, happy with a
sole trader, cap at 6, flood, 502); fetchCompanySuggestions (every
outcome); toCompanySuggestion; reducer (pick equals typed orgnr after TIC,
TIC overrides prefill, TIC off keeps the AB past form and name, unmapped
form falls to the picker, sole trader confirms the name, replaces a
previous orgnr, ignored off-step); SCB client sole-trader option.

Fixes #2448

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YNDuYBHVu172tesKfJmcmi

* fix(onboarding): the suggestion list stays visible and stands alone (skeptic on e56eb242c)

Three independent refuters on the frozen commit; every refutation that
stood is fixed here.

- The listbox was position: absolute inside the field, but the step
  scrolls (.jny-qstep is overflow-y: auto), so the list was clipped to
  the first row and mouse picks were unreachable (measured in headless
  Chrome). It now renders in flow under the field, where the chip row
  from #2421 already lives.
- After Enter on a name (the #2421 path), SEARCH_RESULT flipped
  lookupPending back and the debounced effect refetched SCB, laying the
  listbox over the chip row or next to the nomatch note. The effect is
  now quiet while searchHits is non-empty and for text the user already
  confirmed (Enter or a pick), until the text changes.
- The "many matches, type more" hint only rendered inside the list, so
  the flood case (SCB counts over 100 rows and sends none) showed
  nothing. The hint now renders on its own for that case.
- app/companies/new-client (byrå adds a client) renders the same journey
  and now passes companySearchEnabled like the other two pages.
- A stale mouse highlight could commit a row from the previous text on
  Enter: typing resets the highlight.
- Any 503 switched the picker off for the session; only the route's own
  SCB_NOT_CONFIGURED does now.
- NOTFOUND_EDIT / CEASED_EDIT dropped only the number and kept the
  abandoned pick's name and form, which a later TIC error path would
  have written into the company. Both now drop name and form too, unless
  they came from BankID's CompanyRoles prefill, which is not about the
  number.

Not changed, recorded: a sole trader picked from SCB whom TIC does not
know lands on the "no company on that number" step with the name in the
field; the flow continues with the SCB name prefilled. The search JSON
carries the personnummer of sole-trader rows to the authenticated
browser (the row prints "Enskild firma"), same class as #2421's Enter
search; flagged to the founder.

Refs #2448

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YNDuYBHVu172tesKfJmcmi

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-09 11:43:14 +02:00

1434 lines
51 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client'
import { useCallback, useEffect, useMemo, useReducer, useRef, useState, type KeyboardEvent } from 'react'
import { useRouter } from 'next/navigation'
import Link from 'next/link'
import { useLocale, useTranslations } from 'next-intl'
import { createCompanyFromOnboarding } from '@/lib/company/actions'
import { computeFiscalPeriod } from '@/lib/company/compute-fiscal-period'
import { deriveFirstYearDefaults } from '@/lib/company/first-year-defaults'
import { parseStartMonthDay } from '@/lib/company/first-year-defaults'
import {
fetchCompanyLookup,
fetchCompanySearch,
fetchCompanySuggestions,
} from '@/lib/company-lookup/fetch-company-lookup'
import { normalizeOrgNumber } from '@/lib/company-lookup/normalize-org-number'
import {
COMPANY_SEARCH_MIN_CHARS,
type CompanySearchHit,
type CompanySuggestion,
} from '@/lib/company-lookup/types'
import { mapEntityType } from '@/lib/company-lookup/entity-type-map'
import { formatOrgNumber } from '@/lib/utils'
import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions'
import { useBranding } from '@/lib/branding/brand-context'
import posthog from 'posthog-js'
import { isAnalyticsEnabled } from '@/lib/analytics/enabled'
import {
BRANCH_PROVIDERS,
branchDestination,
type BranchChoice,
} from '@/lib/onboarding-journey/branch'
import {
initJourney,
journeyReducer,
stationOfStep,
type JourneyState,
} from '@/lib/onboarding-journey/reducer'
import {
abFirstYearEndOptions,
efFirstYearEndOptions,
type FirstYearEndOption,
} from '@/lib/onboarding-journey/fiscal-options'
import type { EntityType } from '@/types'
import { isEntityTypeCreatable, usesPersonnummerAsOrgNumber } from '@/lib/company/entity-type'
import JourneyOrb, { type OrbState } from './JourneyOrb'
/** Display order of the form picker (AB first, as before); flags filter it. */
const FORM_PICKER_ORDER: EntityType[] = ['aktiebolag', 'enskild_firma', 'ideell_forening']
/** i18n key per legal form for the picker chips and the summary card. */
const FORM_LABEL_KEY: Record<EntityType, 'journey_form_ab' | 'journey_form_ef' | 'journey_form_forening'> = {
aktiebolag: 'journey_form_ab',
enskild_firma: 'journey_form_ef',
ideell_forening: 'journey_form_forening',
}
import JourneyTrack from './JourneyTrack'
import Question from './Question'
import ChipRow from './ChipRow'
import YearBand from './YearBand'
import JourneyDatePicker from './JourneyDatePicker'
import AddressFields from './AddressFields'
import { InkText } from './ink'
import './journey.css'
/**
* The journey onboarding flow. Renders the reducer's current step,
* performs the side effects, and collects the exact settings payload the
* wizard sends today.
*
* TIC budget: fetchCompanyLookup fires exactly once per confirmed orgnr
* (Enter, the auto-submitted BankID deep link, or a picked suggestion).
* The search-as-you-type picker under the field is SCB (free), never TIC.
* The advisory dup check is an internal endpoint.
*/
const STATION_FRACS = [0.07, 0.285, 0.5, 0.715, 0.93]
/** Keystroke-to-search delay for the SCB picker: long enough to skip the
* middle of a word, short enough to feel live. */
const SUGGEST_DEBOUNCE_MS = 300
/** Digits, spaces and dashes only: the orgnr path, never a name search. */
function looksLikeOrgNumber(raw: string): boolean {
return /^[\d\s-]+$/.test(raw.trim())
}
const LOG = '[onboarding-journey]'
function logError(message: string, extra?: Record<string, unknown>) {
console.error(LOG, message, extra ?? '')
fetch('/api/log', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message: `onboarding-journey: ${message}`, extra }),
}).catch(() => {})
}
/**
* Branch-question funnel event. Anonymous by design: AnalyticsIdentify only
* mounts in the dashboard layout, so this measures choice distribution, not
* people. Guarded + swallowed like every product capture.
*/
function captureBranch(choice: BranchChoice) {
if (!isAnalyticsEnabled()) return
try {
posthog.capture('onboarding_branch_chosen', { choice })
} catch {
// Telemetry must never affect the journey.
}
}
interface OnboardingJourneyProps {
teamId: string
mode?: 'first' | 'add'
initialOrgNumber?: string
initialEntityType?: EntityType
initialLegalName?: string
/** A pending invitation exists for this user's email: an invitee has
* likely landed here by mistake (lost invite cookie), so the first
* question carries a "join via the link in the email" hint. */
hasPendingInvite?: boolean
/** SCB credentials exist in this environment: the orgnr field suggests
* companies while a name is typed. Off: the field is orgnr-or-Enter. */
companySearchEnabled?: boolean
}
export default function OnboardingJourney({
teamId,
mode = 'first',
initialOrgNumber,
initialEntityType,
initialLegalName,
hasPendingInvite = false,
companySearchEnabled = false,
}: OnboardingJourneyProps) {
const router = useRouter()
const t = useTranslations('onboarding')
const { appName } = useBranding()
const locale = useLocale()
const ticEnabled = ENABLED_EXTENSION_IDS.has('tic')
const [state, dispatch] = useReducer(
journeyReducer,
{ mode, initialOrgNumber, initialEntityType, initialLegalName },
initJourney,
)
const bandRef = useRef<HTMLDivElement | null>(null)
// Latches after the first done-screen branch choice (see onBranch below).
const branchChosenRef = useRef(false)
const [orgInput, setOrgInput] = useState(initialOrgNumber ?? '')
const [orgShake, setOrgShake] = useState(false)
const [thinking, setThinking] = useState(false)
const [narration, setNarration] = useState<string | null>(null)
const [monogram, setMonogram] = useState<string | null>(null)
const [dupName, setDupName] = useState<string | null>(null)
const [dupElsewhere, setDupElsewhere] = useState(false)
// The SCB picker: rows for the current text, whether SCB cut the list,
// and the keyboard-highlighted row (-1: none, Enter runs the Enter path).
const [suggestions, setSuggestions] = useState<CompanySuggestion[]>([])
const [suggestTruncated, setSuggestTruncated] = useState(false)
const [suggestActive, setSuggestActive] = useState(-1)
// Set once the environment answers 503: stops every further call.
const suggestDisabled = useRef(!companySearchEnabled)
// The text the user last confirmed (Enter, or a picked row): the picker
// does not reopen for it, so the #2421 chip row or the nomatch note
// stands alone until the text changes.
const lastConfirmed = useRef<string | null>(null)
const station = stationOfStep(state.step)
const entity = state.settings.entity_type
const isEf = entity === 'enskild_firma'
const monthLong = useMemo(() => {
const fmt = new Intl.DateTimeFormat(locale === 'en' ? 'en' : 'sv', { month: 'long' })
return Array.from({ length: 12 }, (_, i) => fmt.format(new Date(2026, i, 1)))
}, [locale])
const monthShort = useMemo(() => {
const fmt = new Intl.DateTimeFormat(locale === 'en' ? 'en' : 'sv', { month: 'short' })
return Array.from({ length: 12 }, (_, i) => fmt.format(new Date(2026, i, 1)).replace('.', ''))
}, [locale])
const formatDayMonthYear = useCallback(
(isoDate: string) => {
const [y, m, d] = isoDate.split('-').map(Number)
return `${d} ${monthShort[(m ?? 1) - 1]} ${y}`
},
[monthShort],
)
/* ── side effects ─────────────────────────────────────────────── */
// One lookup per confirmed orgnr: fired from the submit handler, never
// from typing. The dup check (internal endpoint) rides along, advisory.
const shakeOrg = useCallback(() => {
setOrgShake(true)
window.setTimeout(() => setOrgShake(false), 400)
}, [])
const checkDuplicate = useCallback((orgNumber: string) => {
setDupName(null)
setDupElsewhere(false)
fetch(`/api/company/check-org-number?org_number=${encodeURIComponent(orgNumber)}`)
.then(async (res) => {
if (!res.ok) return
const { data } = await res.json()
setDupName(data?.companies?.[0]?.name ?? null)
setDupElsewhere(Boolean(data?.exists_elsewhere))
})
.catch(() => {})
}, [])
// The one field takes either an orgnr or a company name. Digits (with
// dashes/spaces) are always the orgnr path, so a mistyped number shakes
// instead of turning into a name search; anything else is a name.
const submitOrg = useCallback(
(raw: string) => {
const trimmed = raw.trim()
const looksNumeric = /^[\d\s-]+$/.test(trimmed)
if (looksNumeric) {
if (normalizeOrgNumber(trimmed) === null) {
shakeOrg()
return
}
dispatch({ type: 'ORG_SUBMITTED', orgNumber: trimmed })
fetchCompanyLookup(trimmed, { ticEnabled }).then((outcome) => {
dispatch({ type: 'LOOKUP_RESULT', outcome })
})
checkDuplicate(trimmed)
return
}
if (!ticEnabled || trimmed.length < COMPANY_SEARCH_MIN_CHARS) {
shakeOrg()
return
}
// A previous orgnr's "you already have X" note must not sit above the
// chip row; the pick re-checks for the number it resolves to.
setDupName(null)
setDupElsewhere(false)
lastConfirmed.current = trimmed
dispatch({ type: 'SEARCH_SUBMITTED', query: trimmed })
fetchCompanySearch(trimmed, { ticEnabled }).then((outcome) => {
dispatch({ type: 'SEARCH_RESULT', outcome })
if (outcome.status === 'found' && outcome.hits.length === 1) {
checkDuplicate(outcome.hits[0].orgNumber)
}
})
},
[ticEnabled, shakeOrg, checkDuplicate],
)
// The field keeps the name the user typed: writing the picked number into
// it would print a sole trader's personnummer in plain text on Back, the
// one thing the chip row avoids. Back re-searches the name instead.
const pickSearchHit = useCallback(
(hit: CompanySearchHit) => {
dispatch({ type: 'SEARCH_HIT_PICKED', hit })
checkDuplicate(hit.orgNumber)
},
[checkDuplicate],
)
// Search-as-you-type: SCB per debounced keystroke while the text is a
// name of three or more characters. A newer keystroke aborts the request
// in flight, and a response for text the user has since left is dropped,
// so the list never lags behind the field. Costs no TIC. Quiet while the
// Enter path shows its chip row and for text already confirmed.
useEffect(() => {
const query = orgInput.trim()
if (
suggestDisabled.current ||
state.step !== 'orgnr' ||
state.lookupPending ||
state.searchHits.length > 0 ||
query === lastConfirmed.current
) {
setSuggestions([])
setSuggestTruncated(false)
setSuggestActive(-1)
return
}
if (query.length < COMPANY_SEARCH_MIN_CHARS || looksLikeOrgNumber(query)) {
setSuggestions([])
setSuggestTruncated(false)
setSuggestActive(-1)
return
}
const controller = new AbortController()
const timer = window.setTimeout(() => {
fetchCompanySuggestions(query, { signal: controller.signal }).then((outcome) => {
if (controller.signal.aborted) return
if (outcome.status === 'disabled') suggestDisabled.current = true
const rows = outcome.status === 'found' ? outcome.suggestions : []
setSuggestions(rows)
setSuggestTruncated(outcome.status === 'found' || outcome.status === 'empty' ? outcome.truncated : false)
setSuggestActive(-1)
})
}, SUGGEST_DEBOUNCE_MS)
return () => {
window.clearTimeout(timer)
controller.abort()
}
}, [orgInput, state.step, state.lookupPending, state.searchHits.length])
// A picked suggestion is an orgnr the user confirmed: the same single TIC
// lookup as Enter on a typed number, plus the advisory dup check. The
// field shows the company's name, never its number (a sole trader's is
// their personnummer); on Back the name stands until the user edits it.
const pickSuggestion = useCallback(
(suggestion: CompanySuggestion) => {
setSuggestions([])
setSuggestTruncated(false)
setSuggestActive(-1)
lastConfirmed.current = suggestion.name.trim()
setOrgInput(suggestion.name)
setDupName(null)
setDupElsewhere(false)
dispatch({ type: 'SUGGESTION_PICKED', suggestion })
fetchCompanyLookup(suggestion.orgNumber, { ticEnabled }).then((outcome) => {
dispatch({ type: 'LOOKUP_RESULT', outcome })
})
checkDuplicate(suggestion.orgNumber)
},
[ticEnabled, checkDuplicate],
)
const onOrgKeyDown = useCallback(
(e: KeyboardEvent<HTMLInputElement>) => {
if (state.lookupPending) return
const open = suggestions.length > 0
if (open && e.key === 'ArrowDown') {
e.preventDefault()
setSuggestActive((i) => (i + 1) % suggestions.length)
return
}
if (open && e.key === 'ArrowUp') {
e.preventDefault()
setSuggestActive((i) => (i <= 0 ? suggestions.length - 1 : i - 1))
return
}
if (open && e.key === 'Escape') {
e.preventDefault()
setSuggestions([])
setSuggestActive(-1)
return
}
if (e.key === 'Enter') {
const active = suggestActive >= 0 ? suggestions[suggestActive] : undefined
if (active) {
e.preventDefault()
pickSuggestion(active)
return
}
submitOrg(orgInput)
}
},
[state.lookupPending, suggestions, suggestActive, pickSuggestion, submitOrg, orgInput],
)
// BankID deep link: auto-submit the orgnr once on mount (the single
// lookup replaces the wizard's preverified suppression, per the plan
// addendum). Guarded against strict-mode double-invoke.
const autoRan = useRef(false)
useEffect(() => {
if (autoRan.current) return
autoRan.current = true
// At mount the reducer is always on the orgnr step.
if (initialOrgNumber) submitOrg(initialOrgNumber)
}, [initialOrgNumber, submitOrg])
// One choice only: rapid clicks on different chips must not race two
// PATCHes (last-write-wins could persist the wrong path after navigation).
const onBranch = useCallback(
(choice: BranchChoice) => {
if (branchChosenRef.current) return
branchChosenRef.current = true
const dest = branchDestination(choice)
if (dest.path) {
// Fire-and-forget: the checklist path is a nicety, routing is
// the point. A lost PATCH just leaves the Hem checklist
// unpathed; it must never block or delay the navigation.
fetch('/api/onboarding/state', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path: dest.path }),
keepalive: true,
}).catch(() => logError('branch path persist failed', { choice }))
}
captureBranch(choice)
router.push(dest.href)
},
[router],
)
// A short thinking beat between questions.
const prevStep = useRef(state.step)
useEffect(() => {
if (prevStep.current === state.step) return
prevStep.current = state.step
if (state.step === 'done' || state.step === 'source' || state.submitting) return
setThinking(true)
const timer = window.setTimeout(() => setThinking(false), 420)
return () => window.clearTimeout(timer)
}, [state.step, state.submitting])
// The finale: validate the period, narrate the real server steps while
// createCompanyFromOnboarding runs, then check + monogram.
const submitRan = useRef(false)
useEffect(() => {
if (!state.submitting || submitRan.current) return
submitRan.current = true
const s = state
const periodResult = computeFiscalPeriod(s.settings)
if (periodResult.error) {
logError('journey period validation failed', { error: periodResult.error })
submitRan.current = false
dispatch({ type: 'SUBMIT_FAILED', code: 'period_invalid' })
return
}
const msgs = [
t('journey_shaping_accounts'),
t('journey_shaping_period', { name: periodResult.periodName }),
s.settings.vat_registered ? t('journey_shaping_vat') : t('journey_shaping_deadlines'),
]
const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches
const timers: number[] = []
msgs.forEach((m, i) => {
timers.push(window.setTimeout(() => setNarration(m), reduced ? 0 : i * 850))
})
createCompanyFromOnboarding({
teamId,
settings: s.settings as Record<string, unknown>,
fiscalPeriod: {
startDate: periodResult.startStr,
endDate: periodResult.endStr,
name: periodResult.periodName,
},
ticLookup: s.ticLookup,
})
.then((result) => {
timers.forEach((id) => window.clearTimeout(id))
setNarration(null)
submitRan.current = false
if (result.error || !result.companyId) {
logError('journey create company failed', { error: result.error })
dispatch({
type: 'SUBMIT_FAILED',
code: result.error === 'org_number_invalid' ? 'org_number_invalid' : 'generic',
})
return
}
dispatch({ type: 'SUBMIT_SUCCEEDED' })
const initial = (s.settings.company_name || 'A').trim().charAt(0).toUpperCase()
window.setTimeout(() => setMonogram(initial), reduced ? 0 : 1600)
})
.catch((err) => {
timers.forEach((id) => window.clearTimeout(id))
setNarration(null)
submitRan.current = false
logError('journey create company threw', {
error: err instanceof Error ? err.message : String(err),
})
dispatch({ type: 'SUBMIT_FAILED', code: 'generic' })
})
}, [state, t, teamId])
/* ── derived display ──────────────────────────────────────────── */
const orbState: OrbState = state.step === 'done' || state.step === 'source'
? 'check'
: state.submitting
? narration === null
? 'working'
: 'shaping'
: state.lookupPending
? 'searching'
: thinking
? 'thinking'
: 'listening'
const fyAnswer = useMemo(() => {
const s = state.settings
if (s.is_first_fiscal_year && s.first_year_start && s.first_year_end) {
return t('journey_ans_first', {
from: formatDayMonthYear(s.first_year_start),
to: formatDayMonthYear(s.first_year_end),
})
}
if (s.fiscal_year_start_month === undefined) return null
if (s.fiscal_year_start_month === 1) return t('journey_ans_calendar')
return t('journey_ans_broken', {
from: monthShort[s.fiscal_year_start_month - 1],
to: monthShort[(s.fiscal_year_start_month + 10) % 12],
})
}, [state.settings, t, monthShort, formatDayMonthYear])
const momsAnswer = state.settings.vat_registered === false
? t('journey_ans_vat_none')
: state.settings.moms_period
? t(`journey_ans_${state.settings.moms_period}`)
: null
const methodAnswer = state.settings.accounting_method
? state.settings.accounting_method === 'accrual'
? t('journey_method_accrual')
: t('journey_method_cash')
: null
const stations = [
{ label: t('journey_station_company'), answer: station > 0 ? (state.settings.company_name ?? null) : null },
{ label: t('journey_station_fiscal'), answer: station > 1 ? fyAnswer : null },
{ label: t('journey_station_vat'), answer: station > 2 ? momsAnswer : null },
{ label: t('journey_station_method'), answer: station > 3 ? methodAnswer : null },
{ label: t('journey_station_done') },
]
const lookupFacts = useMemo(() => {
const lk = state.ticLookup
if (!lk || station > 0) return []
const facts: { text: string; warn?: boolean }[] = []
if (entity) facts.push({ text: t(FORM_LABEL_KEY[entity]) })
if (lk.address?.city) facts.push({ text: lk.address.city })
if (lk.sniCodes[0]?.name) facts.push({ text: lk.sniCodes[0].name })
if (lk.registration.fTax) facts.push({ text: 'F-skatt' })
if (lk.registration.vat) facts.push({ text: t('journey_fact_vat') })
if (lk.isCeased) facts.push({ text: t('journey_fact_ceased'), warn: true })
return facts
}, [state.ticLookup, station, entity, t])
/* ── per-step question rendering ──────────────────────────────── */
const flyProps = { flyTargetRef: bandRef, flyTargetFrac: STATION_FRACS[station] }
function renderStep() {
const s = state.settings
switch (state.step) {
case 'orgnr':
return (
<Question
title={companySearchEnabled || ticEnabled ? t('journey_company_title') : t('journey_orgnr_title')}
sub={hasPendingInvite ? t('journey_pending_invite_note') : undefined}
attn={
state.serverError === 'org_number_invalid'
? t('journey_err_org_invalid')
: state.lookupNote === 'error'
? t('journey_lookup_error')
: state.lookupNote === 'nomatch'
? t('journey_search_nomatch')
: undefined
}
>
<div className={`jny-biginput${orgShake ? ' is-err' : ''}`} style={{ marginTop: 26 }}>
<input
value={orgInput}
inputMode="text"
placeholder={companySearchEnabled || ticEnabled ? t('journey_company_placeholder') : '556677-8899'}
aria-label={t('step2_org_number_label')}
autoComplete="off"
autoFocus
disabled={state.lookupPending}
role="combobox"
aria-autocomplete="list"
aria-expanded={suggestions.length > 0}
aria-controls="jny-suggest-list"
aria-activedescendant={suggestActive >= 0 ? `jny-suggest-${suggestActive}` : undefined}
onChange={(e) => {
// A highlight belongs to the rows for the previous text.
setSuggestActive(-1)
setOrgInput(e.target.value)
}}
onKeyDown={onOrgKeyDown}
/>
</div>
{/* In flow, not floated: the step scrolls (overflow-y: auto), so an
absolutely positioned list would be clipped to the field. */}
{suggestions.length > 0 ? (
<>
<ul id="jny-suggest-list" role="listbox" aria-label={t('journey_suggest_label')} className="jny-suggest">
{suggestions.map((s, i) => {
// A sole trader's org number is their personnummer:
// the row names the form instead, never the number.
const isSoleTrader = mapEntityType(s.legalEntityType) === 'enskild_firma'
const ident = isSoleTrader ? t('journey_form_ef') : formatOrgNumber(s.orgNumber)
const sub = [ident, s.city, s.active ? null : t('journey_suggest_inactive')]
.filter(Boolean)
.join(' · ')
return (
<li
key={s.orgNumber}
id={`jny-suggest-${i}`}
role="option"
aria-selected={i === suggestActive}
className={`${i === suggestActive ? 'is-active' : ''}${s.active ? '' : ' is-inactive'}`}
onMouseEnter={() => setSuggestActive(i)}
// mousedown, not click: the input's blur must not close the list first.
onMouseDown={(e) => {
e.preventDefault()
pickSuggestion(s)
}}
>
<span className="jny-sug-name">{s.name}</span>
<span className="jny-sug-sub">{sub}</span>
</li>
)
})}
</ul>
{suggestTruncated ? <p className="jny-enterhint">{t('journey_suggest_more')}</p> : null}
</>
) : suggestTruncated ? (
// SCB counted a flood for a short prefix and sent no rows.
<p className="jny-enterhint">{t('journey_suggest_more')}</p>
) : state.searchHits.length > 1 ? (
<>
<p className="jny-enterhint">{t('journey_search_pick')}</p>
<ChipRow
options={state.searchHits.map((h) => {
// A sole trader's org number is their personnummer: the
// chip names the form instead, never the number.
const isSoleTrader = mapEntityType(h.result.legalEntityType) === 'enskild_firma'
const ident = isSoleTrader ? t('journey_form_ef') : formatOrgNumber(h.orgNumber)
const city = h.result.address?.city
return {
key: h.orgNumber,
label: h.result.companyName || ident,
rec: h.result.companyName ? `${ident}${city ? ` · ${city}` : ''}` : undefined,
}
})}
onPick={(k) => {
const hit = state.searchHits.find((h) => h.orgNumber === k)
if (hit) pickSearchHit(hit)
}}
{...flyProps}
/>
</>
) : (
<p className="jny-enterhint">
{t('journey_press')} <b>Enter</b>
</p>
)}
</Question>
)
case 'notfound':
return (
<Question title={t('journey_notfound_title')} sub={t('journey_notfound_sub')}>
<ChipRow
options={[
{ key: 'continue', label: t('journey_notfound_continue') },
{ key: 'edit', label: t('journey_notfound_edit') },
]}
onPick={(k) =>
dispatch({ type: k === 'continue' ? 'NOTFOUND_CONTINUE' : 'NOTFOUND_EDIT' })
}
{...flyProps}
/>
</Question>
)
case 'ceased':
return (
<Question title={t('journey_ceased_title')} sub={t('journey_ceased_sub')}>
<ChipRow
options={[
{ key: 'continue', label: t('journey_ceased_continue') },
{ key: 'edit', label: t('journey_notfound_edit') },
]}
onPick={(k) =>
dispatch({ type: k === 'continue' ? 'CEASED_CONTINUE' : 'CEASED_EDIT' })
}
{...flyProps}
/>
</Question>
)
case 'form':
return (
<Question title={t('journey_form_title')} info={t('journey_form_info')}>
<ChipRow
options={FORM_PICKER_ORDER.filter(isEntityTypeCreatable).map((key) => ({
key,
label: t(FORM_LABEL_KEY[key]),
}))}
onPick={(k) => dispatch({ type: 'ENTITY_PICKED', entityType: k as EntityType })}
{...flyProps}
/>
</Question>
)
case 'name': {
const suggested = s.company_name ?? ''
return (
<Question
title={isEf ? t('journey_name_ef_title') : t('journey_name_ab_title')}
sub={isEf ? t('journey_name_ef_sub') : t('journey_name_ab_sub')}
>
<NameInput
key={state.step + suggested}
initial={suggested}
placeholder={isEf ? t('journey_name_ef_placeholder') : t('journey_name_ab_placeholder')}
hint={
<>
{t('journey_press')} <b>Enter</b>
</>
}
onSubmit={(name) => dispatch({ type: 'NAME_SUBMITTED', name })}
/>
</Question>
)
}
case 'address':
return (
<Question title={isEf ? t('journey_addr_ef_title') : t('journey_addr_ab_title')}>
<AddressFields
placeholders={{
street: t('step2_street_address'),
postalCode: t('step2_postal_code'),
city: t('step2_city'),
}}
enterHint={
<>
{t('journey_press')} <b>Enter</b>
</>
}
skipLabel={t('journey_addr_skip')}
onSubmit={(v) => dispatch({ type: 'ADDRESS_SUBMITTED', ...v })}
/>
</Question>
)
case 'fskatt':
return (
<Question
title={isEf ? t('journey_fskatt_ef_title') : t('journey_fskatt_ab_title')}
info={t('journey_fskatt_info')}
>
<ChipRow
options={[
{ key: 'yes', label: t('journey_yes') },
{ key: 'no', label: t('journey_fskatt_no') },
]}
onPick={(k) => dispatch({ type: 'FSKATT_ANSWERED', fskatt: k === 'yes' })}
{...flyProps}
/>
</Question>
)
case 'fy': {
if (isEf) {
return (
<Question
title={t('journey_fy_ef_title')}
sub={t('journey_fy_ef_sub')}
info={t('journey_fy_ef_info')}
attn={state.serverError === 'period_invalid' ? t('journey_err_period') : undefined}
>
<ChipRow
options={[
{ key: 'ongoing', label: t('journey_fy_ef_ongoing') },
{ key: 'first', label: t('journey_fy_ef_new') },
]}
onPick={(k) =>
dispatch({ type: k === 'ongoing' ? 'FY_CALENDAR_CONFIRMED' : 'FY_FIRST_SELECTED' })
}
{...flyProps}
/>
</Question>
)
}
const startMonth = state.lookupRan
? parseStartMonthDay(state.ticLookup?.fiscalYear?.startMonthDay)
: null
const firstYearSuggested = state.lookupRan
? deriveFirstYearDefaults(state.ticLookup?.registrationDate, Date.now(), {
noClosedPeriod: state.ticLookup?.fiscalYear == null,
}).isFirstFiscalYear
: false
const confirmMode = startMonth !== null
const title = confirmMode ? t('journey_fy_confirm_title') : t('journey_fy_ask_title')
const sub = confirmMode
? startMonth === 1
? t('journey_fy_confirm_sub_calendar')
: t('journey_fy_confirm_sub_broken', {
from: monthLong[startMonth - 1],
to: monthLong[(startMonth + 10) % 12],
})
: t('journey_fy_ask_sub')
return (
<Question
title={title}
sub={sub}
info={t('journey_fy_info')}
attn={state.serverError === 'period_invalid' ? t('journey_err_period') : undefined}
>
<ChipRow
options={[
{
key: 'confirm',
label: confirmMode ? t('journey_fy_yes') : t('journey_fy_calendar'),
},
{ key: 'other', label: t('journey_fy_other') },
{
key: 'first',
label: t('journey_fy_first'),
rec: firstYearSuggested ? t('journey_suggested') : undefined,
},
]}
onPick={(k) => {
if (k === 'other') dispatch({ type: 'FY_OTHER_SELECTED' })
else if (k === 'first') dispatch({ type: 'FY_FIRST_SELECTED' })
else if (confirmMode && startMonth !== null && startMonth !== 1)
dispatch({ type: 'FY_END_MONTH_PICKED', endMonth: startMonth === 1 ? 12 : startMonth - 1 })
else dispatch({ type: 'FY_CALENDAR_CONFIRMED' })
}}
{...flyProps}
/>
</Question>
)
}
case 'fymonth':
return (
<FyMonthStep
t={t}
monthShort={monthShort}
monthLong={monthLong}
onUse={(m) => dispatch({ type: 'FY_END_MONTH_PICKED', endMonth: m })}
/>
)
case 'fystart': {
const regDate = state.lookupRan ? state.ticLookup?.registrationDate : null
const regIso = regDate
? new Date(regDate).toISOString().slice(0, 10)
: null
return (
<Question
title={isEf ? t('journey_fystart_ef_title') : t('journey_fystart_ab_title')}
sub={
regIso
? t('journey_fystart_reg_sub', { date: formatDayMonthYear(regIso) })
: isEf
? t('journey_fystart_ef_sub')
: t('journey_fystart_ab_sub')
}
>
{regIso ? (
<FyStartWithSuggestion
regIso={regIso}
regLabel={formatDayMonthYear(regIso)}
otherLabel={t('journey_fystart_other')}
onPick={(d) => dispatch({ type: 'FY_START_PICKED', date: d })}
flyProps={flyProps}
/>
) : (
<JourneyDatePicker
years={[new Date().getFullYear() - 1, new Date().getFullYear()]}
onPick={(d) => dispatch({ type: 'FY_START_PICKED', date: d })}
/>
)}
</Question>
)
}
case 'fyend':
return (
<FyEndStep
t={t}
isEf={isEf}
startDate={s.first_year_start ?? ''}
monthShort={monthShort}
formatDate={formatDayMonthYear}
onPick={(o) => dispatch({ type: 'FY_END_PICKED', date: o.date })}
flyProps={flyProps}
/>
)
case 'momsyn':
return (
<Question
title={isEf ? t('journey_momsyn_ef_title') : t('journey_momsyn_ab_title')}
info={t('journey_momsyn_info')}
>
<ChipRow
options={[
{ key: 'yes', label: t('journey_yes') },
{ key: 'no', label: t('journey_no') },
]}
onPick={(k) => dispatch({ type: 'VAT_ANSWERED', registered: k === 'yes' })}
{...flyProps}
/>
</Question>
)
case 'moms': {
let info = t('journey_moms_info')
if (s.vat_number) info += ' ' + t('journey_moms_info_vatnr', { vatNumber: s.vat_number })
return (
<Question
title={isEf ? t('journey_moms_ef_title') : t('journey_moms_ab_title')}
sub={state.lookupRan && state.ticLookup?.registration.vat ? t('journey_moms_sub_registered') : undefined}
info={info}
>
<ChipRow
options={[
{ key: 'quarterly', label: t('journey_moms_quarterly') },
{ key: 'monthly', label: t('journey_moms_monthly') },
{ key: 'yearly', label: t('journey_moms_yearly') },
]}
onPick={(k) =>
dispatch({ type: 'MOMS_PERIOD_PICKED', period: k as 'monthly' | 'quarterly' | 'yearly' })
}
{...flyProps}
/>
</Question>
)
}
case 'method':
return (
<Question
title={isEf ? t('journey_method_ef_title') : t('journey_method_ab_title')}
info={t('journey_method_info')}
attn={state.serverError === 'generic' ? t('journey_err_generic') : undefined}
>
<ChipRow
options={[
{ key: 'accrual', label: t('journey_method_accrual') },
{ key: 'cash', label: t('journey_method_cash') },
]}
onPick={(k) => dispatch({ type: 'METHOD_PICKED', method: k as 'accrual' | 'cash' })}
disabled={state.submitting}
{...flyProps}
/>
</Question>
)
case 'done':
return (
<DoneStep
t={t}
state={state}
mode={mode}
fyAnswer={fyAnswer}
momsAnswer={momsAnswer}
methodAnswer={methodAnswer}
onOpen={() => router.push('/')}
onContinue={() => dispatch({ type: 'DONE_CONTINUE' })}
/>
)
case 'source':
return <SourceStep t={t} onBranch={onBranch} />
}
}
/* ── composition ──────────────────────────────────────────────── */
return (
<div className="jny jny-fixed" style={{ ['--jny-dawn' as string]: String(station / 4) }}>
<div className="jny-dawn" aria-hidden="true" />
{mode === 'add' && state.step !== 'done' ? (
<Link href="/" className="jny-btn-quiet jny-escape">
&lsaquo; {t('journey_cancel_add', { appName })}
</Link>
) : null}
<div className="jny-center">
<div ref={bandRef} style={{ width: '100%' }}>
<JourneyTrack
stations={stations}
active={station}
onJump={
state.submitting
? undefined
: (i) => dispatch({ type: 'STATION_JUMP', station: i as 0 | 1 | 2 | 3 })
}
orbLabel={t(`journey_orb_${orbState}`)}
>
<JourneyOrb state={orbState} targetX={STATION_FRACS[station]} morphChar={monogram} />
</JourneyTrack>
</div>
<div className={`jny-facts${station > 0 && !narration ? ' is-gone' : ''}`} aria-live="polite">
{narration ? (
<div className="jny-factname">
<InkText text={narration} step={40} />
</div>
) : (
<>
<div className="jny-factname">
{state.ticLookup && station === 0 ? (
<InkText text={state.ticLookup.companyName} step={90} />
) : null}
</div>
<div className="jny-factmeta">
{lookupFacts.map((f, i) => (
<span
key={f.text}
className={`jny-f is-on${f.warn ? ' is-warn' : ''}`}
style={{ transitionDelay: `${i * 150}ms` }}
>
{i > 0 ? ' · ' : ''}
{f.text}
</span>
))}
{dupName && station === 0 ? (
<span className="jny-f is-on" style={{ transitionDelay: `${lookupFacts.length * 150}ms` }}>
{lookupFacts.length > 0 ? ' · ' : ''}
{t('journey_dup_note', { name: dupName, appName })}
</span>
) : null}
{!dupName && dupElsewhere && station === 0 ? (
// Cross-account duplicate (#1231): the same org number
// already exists under another Accounted account. Shown
// only when there is no own-account match, which is the
// more specific hint.
<span className="jny-f is-on is-warn" style={{ transitionDelay: `${lookupFacts.length * 150}ms` }}>
{lookupFacts.length > 0 ? ' · ' : ''}
{t('journey_dup_elsewhere_note', { appName })}
</span>
) : null}
</div>
</>
)}
</div>
<div className="jny-qarea">{renderStep()}</div>
<div className="jny-balance" aria-hidden="true" />
<div className="jny-backrow">
{state.history.length > 0 &&
state.step !== 'done' &&
state.step !== 'source' &&
!state.submitting ? (
<button type="button" className="jny-btn-quiet" onClick={() => dispatch({ type: 'BACK' })}>
&lsaquo; {t('back')}
</button>
) : null}
</div>
</div>
</div>
)
}
/* ── small step components ─────────────────────────────────────── */
type TFn = ReturnType<typeof useTranslations<'onboarding'>>
function NameInput({
initial,
placeholder,
hint,
onSubmit,
}: {
initial: string
placeholder: string
hint: React.ReactNode
onSubmit: (name: string) => void
}) {
const [value, setValue] = useState(initial)
return (
<>
<div className="jny-biginput">
<input
value={value}
placeholder={placeholder}
aria-label={placeholder}
autoComplete="off"
autoFocus
onChange={(e) => setValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && value.trim()) onSubmit(value)
}}
/>
</div>
<p className="jny-enterhint">{hint}</p>
</>
)
}
function FyMonthStep({
t,
monthShort,
monthLong,
onUse,
}: {
t: TFn
monthShort: string[]
monthLong: string[]
onUse: (endMonth: number) => void
}) {
const [sel, setSel] = useState(0)
const [hover, setHover] = useState(0)
const m = hover || sel
const year0 = new Date().getFullYear()
const span: [number, number] | null = m ? (m === 12 ? [0, 11] : [m, m + 11]) : null
const label = m
? m === 12
? `1 ${monthLong[0]} – 31 ${monthLong[11]}`
: `1 ${monthLong[m % 12]} – ${monthLong[m - 1]}`
: ''
return (
<Question title={t('journey_fymonth_title')} sub={t('journey_fymonth_sub')} info={t('journey_fymonth_info')}>
<YearBand
cells={24}
year0={year0}
span={span}
label={label}
note={m ? (m === 12 ? t('journey_band_calendar_note') : t('journey_band_broken_note')) : undefined}
/>
<div className="jny-mchips">
{monthShort.map((name, i) => (
<button
key={name}
type="button"
className={`jny-mchip${sel === i + 1 ? ' is-sel' : ''}`}
onMouseEnter={() => setHover(i + 1)}
onMouseLeave={() => setHover(0)}
onClick={() => setSel(i + 1)}
>
{name}
</button>
))}
</div>
{sel ? (
<div className="jny-qactions">
<button type="button" className="jny-btn" onClick={() => onUse(sel)}>
{t('journey_fymonth_use')}
</button>
</div>
) : null}
</Question>
)
}
function FyStartWithSuggestion({
regIso,
regLabel,
otherLabel,
onPick,
flyProps,
}: {
regIso: string
regLabel: string
otherLabel: string
onPick: (date: string) => void
flyProps: { flyTargetRef: React.RefObject<HTMLDivElement | null>; flyTargetFrac: number }
}) {
const [manual, setManual] = useState(false)
if (manual) {
return (
<JourneyDatePicker
years={[new Date().getFullYear() - 1, new Date().getFullYear()]}
onPick={onPick}
/>
)
}
return (
<ChipRow
options={[
{ key: 'reg', label: regLabel },
{ key: 'other', label: otherLabel },
]}
onPick={(k) => {
if (k === 'reg') onPick(regIso)
else setManual(true)
}}
{...flyProps}
/>
)
}
function FyEndStep({
t,
isEf,
startDate,
monthShort,
formatDate,
onPick,
flyProps,
}: {
t: TFn
isEf: boolean
startDate: string
monthShort: string[]
formatDate: (iso: string) => string
onPick: (o: FirstYearEndOption) => void
flyProps: { flyTargetRef: React.RefObject<HTMLDivElement | null>; flyTargetFrac: number }
}) {
const [endMonth, setEndMonth] = useState(12)
const [showMonths, setShowMonths] = useState(false)
const [preview, setPreview] = useState<FirstYearEndOption | null>(null)
const [sy, sm] = startDate.split('-').map(Number)
const options = isEf
? efFirstYearEndOptions(sy, sm)
: abFirstYearEndOptions(sy, sm, endMonth)
const cells: 24 | 36 = sm - 1 + 19 > 24 ? 36 : 24
const span: [number, number] | null = preview
? [sm - 1, (preview.year - sy) * 12 + preview.month - 1]
: [sm - 1, sm - 1]
return (
<Question
title={t('journey_fyend_title')}
sub={t('journey_fyend_sub')}
info={isEf ? t('journey_fyend_ef_info') : t('journey_fyend_ab_info')}
>
<YearBand
cells={cells}
year0={sy}
span={span}
label={
preview
? `${formatDate(startDate)} – ${formatDate(preview.date)}`
: t('journey_fyend_start_label', { date: formatDate(startDate) })
}
note={
preview
? `${t('journey_fyend_months', { count: preview.months })}${
preview.months > 12
? ` · ${t('journey_fyend_extended')}`
: preview.months < 12
? ` · ${t('journey_fyend_short')}`
: ''
}`
: options.length === 0
? t('journey_fyend_none')
: ''
}
/>
<div className="jny-chips" style={{ marginTop: 20 }}>
{options.map((o) => (
<button
key={o.date}
type="button"
className="jny-pick"
onMouseEnter={() => setPreview(o)}
onClick={() => onPick(o)}
>
{formatDate(o.date)}
<span className="jny-rec">{t('journey_fyend_months', { count: o.months })}</span>
</button>
))}
{!isEf ? (
<button type="button" className="jny-pick" onClick={() => setShowMonths(true)}>
{t('journey_fyend_other_month')}
</button>
) : null}
</div>
{showMonths ? (
<div className="jny-mchips">
{monthShort.map((name, i) => (
<button
key={name}
type="button"
className={`jny-mchip${endMonth === i + 1 ? ' is-sel' : ''}`}
onClick={() => {
setEndMonth(i + 1)
setPreview(null)
}}
>
{name}
</button>
))}
</div>
) : null}
<span style={{ display: 'none' }}>{flyProps.flyTargetFrac}</span>
</Question>
)
}
function DoneStep({
t,
state,
mode,
fyAnswer,
momsAnswer,
methodAnswer,
onOpen,
onContinue,
}: {
t: TFn
state: JourneyState
mode: 'first' | 'add'
fyAnswer: string | null
momsAnswer: string | null
methodAnswer: string | null
onOpen: () => void
onContinue: () => void
}) {
const { appName } = useBranding()
const s = state.settings
const shortName = (s.company_name ?? '').split(' ')[0] || ''
const rows: [string, string][] = [
[t('journey_card_form'), s.entity_type ? t(FORM_LABEL_KEY[s.entity_type]) : ''],
]
if (s.org_number) {
rows.push([
s.entity_type && usesPersonnummerAsOrgNumber(s.entity_type) ? t('journey_card_persnr') : t('journey_card_orgnr'),
s.org_number,
])
}
if (s.city) rows.push([t('journey_card_seat'), s.city])
if (fyAnswer) rows.push([t('journey_card_fy'), fyAnswer])
if (s.f_skatt !== undefined) {
rows.push([t('journey_card_fskatt'), s.f_skatt ? t('journey_card_fskatt_yes') : t('journey_card_fskatt_pending')])
}
if (momsAnswer) rows.push([t('journey_card_vat'), momsAnswer])
if (methodAnswer) rows.push([t('journey_card_method'), methodAnswer])
const notes: string[] = []
if (s.is_first_fiscal_year && s.first_year_start && s.first_year_end) {
notes.push(t('journey_note_first_year', { start: s.first_year_start, end: s.first_year_end }))
}
if (s.f_skatt === false) notes.push(t('journey_note_fskatt'))
if (s.vat_registered === false) notes.push(t('journey_note_vat_watch'))
if (state.ticLookup?.isCeased) notes.push(t('journey_note_ceased'))
return (
<div className="jny-qstep">
<h1 className="jny-qtitle">
<InkText text={t('journey_done_title', { name: shortName })} />
</h1>
<div className="jny-card">
<div className="jny-card-eyebrow">{t('journey_card_eyebrow')}</div>
<div className="jny-card-name">{s.company_name}</div>
<dl>
{rows.map(([k, v], i) => (
<CardRow key={k} label={k} value={v} delay={250 + i * 140} />
))}
</dl>
</div>
{notes.length > 0 ? (
<div className="jny-notes">
{notes.map((n, i) => (
<NoteLine key={n} text={n} delay={1000 + i * 260} />
))}
</div>
) : null}
{mode === 'first' ? (
<Reveal delay={notes.length > 0 ? 1400 + notes.length * 260 : 1200}>
<div className="jny-qactions">
<button type="button" className="jny-btn" onClick={onContinue}>
{t('journey_done_continue')}
</button>
</div>
</Reveal>
) : (
<div className="jny-qactions">
<button type="button" className="jny-btn" onClick={onOpen}>
{t('journey_open_app', { appName })}
</button>
</div>
)}
</div>
)
}
/**
* The branch question as its own step, sharing the Klart station with the
* welcome screen (same station grammar as momsyn/moms under Momsen).
* Providers and the SIE file share one grid of generously sized tiles; the
* provider tiles carry the real logo on a small white mark (the LogoMark
* grammar from NewUserChecklist), the text answers stay equal-weight tiles.
*/
function SourceStep({ t, onBranch }: { t: TFn; onBranch: (choice: BranchChoice) => void }) {
return (
<Question title={t('journey_done_source_title')} sub={t('journey_done_source_sub')}>
<div className="jny-srcgrid">
{BRANCH_PROVIDERS.map((p) => (
<button key={p.id} type="button" className="jny-srcpick" onClick={() => onBranch(p.id)}>
<span className="jny-srcmark">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={p.logo} alt="" />
</span>
{p.name}
</button>
))}
<button type="button" className="jny-srcpick is-text" onClick={() => onBranch('sie')}>
{t('journey_done_source_sie')}
</button>
<button type="button" className="jny-srcpick is-text is-span" onClick={() => onBranch('fresh')}>
{t('journey_done_source_fresh')}
</button>
</div>
<div className="jny-qactions">
<button type="button" className="jny-btn-quiet" onClick={() => onBranch('skip')}>
{t('journey_done_source_skip')}
</button>
</div>
</Question>
)
}
/** Delayed mount so the continue action enters (with the standard .jny-qstep
* rise) only after the profile card and notes have finished settling. */
function Reveal({ delay, children }: { delay: number; children: React.ReactNode }) {
const [on, setOn] = useState(false)
useEffect(() => {
const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches
const id = window.setTimeout(() => setOn(true), reduced ? 0 : delay)
return () => window.clearTimeout(id)
}, [delay])
if (!on) return null
return <div className="jny-qstep">{children}</div>
}
function CardRow({ label, value, delay }: { label: string; value: string; delay: number }) {
const [on, setOn] = useState(false)
useEffect(() => {
const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches
const id = window.setTimeout(() => setOn(true), reduced ? 0 : delay)
return () => window.clearTimeout(id)
}, [delay])
return (
<div className={`jny-card-row${on ? ' is-on' : ''}`}>
<dt>{label}</dt>
<dd>{value}</dd>
</div>
)
}
function NoteLine({ text, delay }: { text: string; delay: number }) {
const [on, setOn] = useState(false)
useEffect(() => {
const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches
const id = window.setTimeout(() => setOn(true), reduced ? 0 : delay)
return () => window.clearTimeout(id)
}, [delay])
return <div className={on ? 'is-on' : undefined}>{text}</div>
}