'use client' import { useCallback, useEffect, useMemo, useReducer, useRef, useState } 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 } from '@/lib/company-lookup/fetch-company-lookup' import { normalizeOrgNumber } from '@/lib/company-lookup/normalize-org-number' import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions' 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 JourneyOrb, { type OrbState } from './JourneyOrb' 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, or the auto-submitted BankID deep link). No debounce-per-key, * no roster prefetch. The advisory dup check is an internal endpoint. */ const STATION_FRACS = [0.07, 0.285, 0.5, 0.715, 0.93] const LOG = '[onboarding-journey]' function logError(message: string, extra?: Record) { console.error(LOG, message, extra ?? '') fetch('/api/log', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ message: `onboarding-journey: ${message}`, extra }), }).catch(() => {}) } 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 } export default function OnboardingJourney({ teamId, mode = 'first', initialOrgNumber, initialEntityType, initialLegalName, hasPendingInvite = false, }: OnboardingJourneyProps) { const router = useRouter() const t = useTranslations('onboarding') const locale = useLocale() const ticEnabled = ENABLED_EXTENSION_IDS.has('tic') const [state, dispatch] = useReducer( journeyReducer, { mode, initialOrgNumber, initialEntityType, initialLegalName }, initJourney, ) const bandRef = useRef(null) const [orgInput, setOrgInput] = useState(initialOrgNumber ?? '') const [orgShake, setOrgShake] = useState(false) const [thinking, setThinking] = useState(false) const [narration, setNarration] = useState(null) const [monogram, setMonogram] = useState(null) const [dupName, setDupName] = useState(null) const [dupElsewhere, setDupElsewhere] = useState(false) 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 submitOrg = useCallback( (raw: string) => { const normalized = normalizeOrgNumber(raw) if (normalized === null) { setOrgShake(true) window.setTimeout(() => setOrgShake(false), 400) return } setDupName(null) setDupElsewhere(false) dispatch({ type: 'ORG_SUBMITTED', orgNumber: raw }) fetchCompanyLookup(raw, { ticEnabled }).then((outcome) => { dispatch({ type: 'LOOKUP_RESULT', outcome }) }) fetch(`/api/company/check-org-number?org_number=${encodeURIComponent(raw)}`) .then(async (res) => { if (!res.ok) return const { data } = await res.json() setDupName(data?.companies?.[0]?.name ?? null) setDupElsewhere(Boolean(data?.exists_elsewhere)) }) .catch(() => {}) }, [ticEnabled], ) // 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]) // 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.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, 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' ? '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: entity === 'aktiebolag' ? t('journey_form_ab') : t('journey_form_ef') }) 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 (
setOrgInput(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter' && !state.lookupPending) submitOrg(orgInput) }} />

{t('journey_press')} Enter

) case 'notfound': return ( dispatch({ type: k === 'continue' ? 'NOTFOUND_CONTINUE' : 'NOTFOUND_EDIT' }) } {...flyProps} /> ) case 'ceased': return ( dispatch({ type: k === 'continue' ? 'CEASED_CONTINUE' : 'CEASED_EDIT' }) } {...flyProps} /> ) case 'form': return ( dispatch({ type: 'ENTITY_PICKED', entityType: k as EntityType })} {...flyProps} /> ) case 'name': { const suggested = s.company_name ?? '' return ( {t('journey_press')} Enter } onSubmit={(name) => dispatch({ type: 'NAME_SUBMITTED', name })} /> ) } case 'address': return ( {t('journey_press')} Enter } skipLabel={t('journey_addr_skip')} onSubmit={(v) => dispatch({ type: 'ADDRESS_SUBMITTED', ...v })} /> ) case 'fskatt': return ( dispatch({ type: 'FSKATT_ANSWERED', fskatt: k === 'yes' })} {...flyProps} /> ) case 'fy': { if (isEf) { return ( dispatch({ type: k === 'ongoing' ? 'FY_CALENDAR_CONFIRMED' : 'FY_FIRST_SELECTED' }) } {...flyProps} /> ) } const startMonth = state.lookupRan ? parseStartMonthDay(state.ticLookup?.fiscalYear?.startMonthDay) : null const firstYearSuggested = state.lookupRan ? deriveFirstYearDefaults(state.ticLookup?.registrationDate).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 ( { 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} /> ) } case 'fymonth': return ( 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 ( {regIso ? ( dispatch({ type: 'FY_START_PICKED', date: d })} flyProps={flyProps} /> ) : ( dispatch({ type: 'FY_START_PICKED', date: d })} /> )} ) } case 'fyend': return ( dispatch({ type: 'FY_END_PICKED', date: o.date })} flyProps={flyProps} /> ) case 'momsyn': return ( dispatch({ type: 'VAT_ANSWERED', registered: k === 'yes' })} {...flyProps} /> ) case 'moms': { let info = t('journey_moms_info') if (s.vat_number) info += ' ' + t('journey_moms_info_vatnr', { vatNumber: s.vat_number }) return ( dispatch({ type: 'MOMS_PERIOD_PICKED', period: k as 'monthly' | 'quarterly' | 'yearly' }) } {...flyProps} /> ) } case 'method': return ( dispatch({ type: 'METHOD_PICKED', method: k as 'accrual' | 'cash' })} disabled={state.submitting} {...flyProps} /> ) case 'done': return ( router.push('/')} /> ) } } /* ── composition ──────────────────────────────────────────────── */ return (