'use client' import { useEffect, useRef, useState } from 'react' import Link from 'next/link' import { useRouter } from 'next/navigation' import { useTranslations } from 'next-intl' import { Check } from 'lucide-react' import posthog from 'posthog-js' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' import JourneyOrb from '@/components/onboarding/journey/JourneyOrb' import { cn } from '@/lib/utils' import { useErrorToast } from '@/lib/hooks/use-error-toast' import { useFormat } from '@/lib/hooks/use-format' import { isAnalyticsEnabled } from '@/lib/analytics/enabled' import { checklistNumbers, type VatDeadlineLine } from '@/lib/onboarding/checklist' import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions' import { useCapability } from '@/contexts/CompanyContext' import { CAPABILITY } from '@/lib/entitlements/keys' import type { InitialSetupPath, InitialSetupState } from '@/types' interface NewUserChecklistProps { initialState: InitialSetupState className?: string hasBookkeepingImported?: boolean hasBankConnected?: boolean hasSkatteverketConnected?: boolean hasInboxItems?: boolean hasAgentBuilt?: boolean /** Personalized VAT-deadline line for the Skatteverket step (null = say nothing). */ vatLine?: VatDeadlineLine } /** * Activation funnel events, mirroring the one existing product-event site * (lib/support/submit-feedback.ts): guarded, try/caught, no PII in * properties. Sandbox companies never render this block (their * initial_setup is seeded completed+dismissed), so no sandbox gate needed. */ function captureSetup(event: string, properties?: Record) { if (!isAnalyticsEnabled()) return try { posthog.capture(event, properties) } catch { // Telemetry must never affect the checklist. } } /** * First-run getting-started block on Hem, in the founder-picked stepped * shape: a numbered thread (get the books in, connect the bank, connect * Skatteverket, get receipts flowing, build the assistant) on a hairline * spine. * Only the step you are on argues its case: it carries the description and * the partner marks next to a filled action. Steps you have not reached yet * drop the pitch but keep a quiet outline action, so any step stays one * click away (connect Skatteverket before the bank, if that is your order). * Done steps collapse to their title. * The persisted state machine is unchanged (path / completedAt / * dismissedAt via /api/onboarding/state); "Starta från början" records the * fresh path and simply checks off step one. */ export default function NewUserChecklist({ initialState, className, hasBookkeepingImported = false, hasBankConnected = false, hasSkatteverketConnected = false, hasInboxItems = false, hasAgentBuilt = false, vatLine = null, }: NewUserChecklistProps) { const t = useTranslations('initial_setup') const router = useRouter() const showError = useErrorToast() const { formatDateLong } = useFormat() const hasAi = useCapability(CAPABILITY.ai) const [state, setState] = useState(initialState) const [saving, setSaving] = useState(null) // The completion signature: 'verdict' shows the orb check-morph and the // verdict line, 'closing' fades the block, 'done' keeps it retired for the // rest of the session. Plays only in the session that finishes the last // step (companies whose completedAt arrives from the server never see it). const [retiring, setRetiring] = useState<'verdict' | 'closing' | 'done' | null>(null) const retireStartedRef = useRef(false) const hasMigration = ENABLED_EXTENSION_IDS.has('arcim-migration') const hasBanking = ENABLED_EXTENSION_IDS.has('enable-banking') const hasSkatteverket = ENABLED_EXTENSION_IDS.has('skatteverket') const hasInbox = ENABLED_EXTENSION_IDS.has('invoice-inbox') const hasWhatsApp = ENABLED_EXTENSION_IDS.has('whatsapp-inbox') const persist = async ( body: Record, pending: InitialSetupPath | 'dismiss' | 'complete', ): Promise => { setSaving(pending) try { const response = await fetch('/api/onboarding/state', { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }) if (!response.ok) { await showError(response, { context: 'settings' }) return null } const payload = await response.json() as { data: InitialSetupState } setState(payload.data) return payload.data } catch (error) { await showError(error, { context: 'settings' }) return null } finally { setSaving(null) } } const step1Done = hasBookkeepingImported || state.path === 'fresh' const step2Done = hasBankConnected // Companies built without the skatteverket/inbox extensions skip those steps. const step3Done = !hasSkatteverket || hasSkatteverketConnected const step4Done = !hasInbox || hasInboxItems const step5Done = hasAgentBuilt useEffect(() => { // The block retires itself once every step is done; Dölj remains the // manual way out. The signature beat latches via retireStartedRef so a // failed persist can retry the PATCH without replaying the beat. if ( !state.completedAt && step1Done && step2Done && step3Done && step4Done && step5Done && saving === null ) { if (!retireStartedRef.current) { retireStartedRef.current = true setRetiring('verdict') } void persist({ completed: true }, 'complete').then((updated) => { if (updated) captureSetup('onboarding_setup_completed', { path: updated.path }) }) } // persist intentionally stays out: its identity follows the toast hook and // would retrigger this completion sync after every render. // eslint-disable-next-line react-hooks/exhaustive-deps }, [step1Done, step2Done, step3Done, step4Done, step5Done, saving, state.completedAt]) // Beat timing: hold the verdict, then fade, then stay retired. useEffect(() => { if (retiring !== 'verdict') return const toClosing = window.setTimeout(() => setRetiring('closing'), 2600) const toGone = window.setTimeout(() => setRetiring('done'), 3200) return () => { window.clearTimeout(toClosing) window.clearTimeout(toGone) } }, [retiring]) const numbers = checklistNumbers({ hasSkatteverket, hasInbox }) const stepCount = numbers.count if (state.dismissedAt) return null // After the beat, stay retired even while the completion PATCH is still in // flight or retrying: falling through to the full checklist here would // flash it after the verdict already played. A failed PATCH keeps retrying // invisibly; the next visit renders from server truth either way. if (retiring === 'done') return null if (state.completedAt && !retiring) return null if (retiring) { return (
{/* The orb draws at the top quarter of its canvas (CY = height/4), so a 100px canvas clipped to 52px shows exactly the check. */}

{t('completed_verdict')}

) } const goMigration = async () => { const updated = await persist({ path: 'migration' }, 'migration') if (updated) { captureSetup('onboarding_setup_step_started', { step: 'books', path: 'migration' }) router.push(hasMigration ? '/import?mode=migration' : '/import?mode=sie') } } const goFresh = () => void persist({ path: 'fresh' }, 'fresh').then((updated) => { if (updated) captureSetup('onboarding_setup_step_started', { step: 'books', path: 'fresh' }) }) const goBank = async () => { const updated = await persist({ path: state.path ?? 'bank' }, 'bank') if (updated) { captureSetup('onboarding_setup_step_started', { step: 'bank' }) router.push(hasBanking ? '/import?mode=psd2' : '/import?mode=bank') } } const goReceipts = () => { captureSetup('onboarding_setup_step_started', { step: 'receipts' }) router.push(hasAi ? '/e/general/invoice-inbox' : '/settings/billing') } const goAssistant = () => { captureSetup('onboarding_setup_step_started', { step: 'assistant' }) router.push(hasAi ? '/onboarding/agent' : '/settings/billing') } const dismiss = () => void persist({ dismissed: true }, 'dismiss').then((updated) => { if (updated) captureSetup('onboarding_setup_dismissed', {}) }) const activeStep = !step1Done ? 1 : !step2Done ? 2 : !step3Done ? 3 : !step4Done ? 4 : 5 return (

{t('title', { count: stepCount })}

    ( )} marks={ <> {t('step_books_sie')} } > {t('step_books_description')}{' '} {' '} {t('step_books_fresh_suffix')} ( )} marks={ hasBanking ? ( ) : undefined } > {t('step_bank_description')} {hasSkatteverket && ( ( )} marks={} > {t('step_skv_description')} {vatLine?.kind === 'date' && ( <> {' '} {t('step_skv_next_vat', { date: formatDateLong(vatLine.dueDate) })} )} {vatLine?.kind === 'missing_period' && ( <> {' '} {t('step_skv_choose_period')} )} )} {hasInbox && ( ( )} marks={ {hasWhatsApp ? t('step_receipts_channels') : t('step_receipts_channels_no_wa')} } > {t('step_receipts_description')} )} ( )} > {t('step_assistant_description')}
) } /** * One numbered step on the thread: dot + hairline down to the next step. * `children` (the pitch) and `marks` (partner logos) render only while this * is the step the user is on. `action` renders on every step that is not * done: filled on the active step, outline on the ones further down, so no * step is ever unreachable just because it is not next in line. * The title row is a constant 36px so it matches the h-9 action button and * the dot (mt-1) centres in it; the spine then runs dot-bottom to dot-top * (top-8 to -bottom-1) without visible breaks. */ function Step({ number, done, active, title, badge, action, marks, last = false, children, }: { number: number done: boolean active: boolean title: string badge?: string action?: (variant: 'default' | 'outline') => React.ReactNode marks?: React.ReactNode last?: boolean children: React.ReactNode }) { const open = active && !done return (
  • {!last && (
  • ) } /** Tiny partner mark: real logo on a white chip; `mono` darkens a white * source logo in light mode and lifts it in dark (Enable Banking). */ function LogoMark({ src, name, mono = false }: { src: string; name: string; mono?: boolean }) { return ( {/* eslint-disable-next-line @next/next/no-img-element */} {name} ) }