* 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>
1434 lines
51 KiB
TypeScript
1434 lines
51 KiB
TypeScript
'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">
|
||
‹ {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' })}>
|
||
‹ {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>
|
||
}
|