diff --git a/app/(onboarding)/onboarding/page.tsx b/app/(onboarding)/onboarding/page.tsx index a438726b..2c2b1fae 100644 --- a/app/(onboarding)/onboarding/page.tsx +++ b/app/(onboarding)/onboarding/page.tsx @@ -1,6 +1,7 @@ import { createClient } from '@/lib/supabase/server' import { redirect } from 'next/navigation' import WelcomeOnboarding from '@/components/dashboard/WelcomeOnboarding' +import OnboardingJourney from '@/components/onboarding/journey/OnboardingJourney' import type { EntityType } from '@/types' import type { EnrichmentCompanyRole } from '@/lib/company-lookup/types' import { mapEntityType as mapTicEntityType } from '@/lib/company-lookup/entity-type-map' @@ -106,6 +107,24 @@ export default async function OnboardingPage({ } } + // Journey rollout flag (onboarding migration PR C): preview first, + // founder click-through, then production. The journey deliberately + // ignores preverifiedOrgNumber: the deep-linked orgnr runs the same + // single Lens lookup as manual entry (plan addendum 2026-07-24), and + // lookup failure degrades to asking the questions instead. + if (process.env.NEXT_PUBLIC_ONBOARDING_JOURNEY === 'true') { + return ( + + ) + } + return ( ) { + 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 + hasExistingCompanies?: boolean + mode?: 'first' | 'add' + initialOrgNumber?: string + initialEntityType?: EntityType + initialLegalName?: string +} + +export default function OnboardingJourney({ + teamId, + mode = 'first', + initialOrgNumber, + initialEntityType, + initialLegalName, +}: 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 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) + 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) + }) + .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 ( +
+