Files
accounted/components/dashboard/WelcomeOnboarding.tsx
T
Jakob WennbergandClaude Fable 5 e2d6c92e3a feat(onboarding): illustrated halftone backdrop from marketing-site art (#1080)
* feat(onboarding): illustrated halftone backdrop from marketing-site art

Ports the gnubok-website halftone illustration set into the onboarding
flow so signup -> app feels like one product:

- OnboardingBackdrop: petal field across the paper background (radially
  masked to stay calm behind the form), Stockholm skyline dissolving
  into the bottom edge, two clouds drifting at reading pace that bounce
  off the viewport and the onboarding panel (IllustrationFloaters,
  physics ported from the website's BouncingFloaters).
- Per-step instrument ghosted in white ink on the dark card header:
  pencil -> notebook -> adding machine -> calculator, one per step.
- Dark mode via invert/hue-rotate filters; prefers-reduced-motion parks
  the floaters; all art is aria-hidden and pointer-events-none.

Applies to /onboarding, /onboarding/agent and /select-company via the
shared (onboarding) layout. No new strings, no API changes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(onboarding): strip backdrop to skyline + step art per founder review

Founder kept the Stadshuset skyline and the per-step header instruments;
the petal field and drifting clouds are cut. Removes the now-unused
floater physics component and the petals/cloud assets.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 15:57:46 +02:00

458 lines
18 KiB
TypeScript

'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import { useTranslations } from 'next-intl'
import { createCompanyFromOnboarding } from '@/lib/company/actions'
import { computeFiscalPeriod } from '@/lib/company/compute-fiscal-period'
import { useToast } from '@/components/ui/use-toast'
import { Building2 } from 'lucide-react'
import { cn } from '@/lib/utils'
import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions'
import { getBranding } from '@/lib/branding/service'
const branding = getBranding()
import type { CompanyLookupResult } from '@/lib/company-lookup/types'
import type { CompanySettings, EntityType, MomsPeriod } from '@/types'
import Step1EntityType from '@/components/onboarding/Step1EntityType'
import Step2CompanyDetails from '@/components/onboarding/Step2CompanyDetails'
import Step3TaxRegistration from '@/components/onboarding/Step3TaxRegistration'
import Step4VatAccounting from '@/components/onboarding/Step4VatAccounting'
import {
ILLUSTRATIONS,
illustrationSrc,
type IllustrationName,
} from '@/components/onboarding/onboarding-illustrations'
// One halftone instrument per step, ghosted in white ink on the dark card
// header. The tools get more computational as the steps get more fiscal:
// pencil (choose form) -> notebook (company details) -> adding machine
// (tax registration) -> calculator (VAT & method).
const STEP_ART: { name: IllustrationName; className: string }[] = [
{ name: 'pencil', className: 'w-28 -rotate-12 right-5 -bottom-6' },
{ name: 'notebook', className: 'w-28 rotate-3 right-4 -bottom-3' },
{ name: 'key-adding-machine', className: 'w-32 -rotate-3 right-2 -bottom-10' },
{ name: 'calculator', className: 'w-28 rotate-3 right-4 -bottom-8' },
]
type TFn = (key: string, values?: Record<string, string | number>) => string
function buildStepInfo(t: TFn) {
return [
{ title: t('step1_title'), subtitle: t('step1_subtitle') },
{ title: t('step2_title'), subtitle: t('step2_subtitle') },
{ title: t('step3_title'), subtitle: t('step3_subtitle') },
{ title: t('step4_title'), subtitle: t('step4_subtitle') },
]
}
function translatePeriodError(msg: string, t: TFn): string {
if (msg.includes('end must be after')) return t('period_error_end_after_start')
if (msg.includes('start must be the 1st')) return t('period_error_start_first')
if (msg.includes('end must be the last day')) return t('period_error_end_last_day')
if (msg.includes('exceeds maximum 18 months')) return t('period_error_max_18')
return t('period_error_invalid')
}
const LOG = '[welcome-onboarding]'
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: `welcome-onboarding: ${message}`, extra }),
}).catch(() => {})
}
// Parse TIC v2's `startMonthDay` ("MM-DD": e.g. "07-01") into a month
// number 1-12. Returns null on missing / malformed input so the caller
// can fall through to the manual picker default.
function parseStartMonthDay(value: string | null | undefined): number | null {
if (!value) return null
const match = /^(\d{1,2})-\d{1,2}$/.exec(value)
if (!match) return null
const month = Number(match[1])
if (!Number.isInteger(month) || month < 1 || month > 12) return null
return month
}
// Derive the Step-3 first-year defaults from TIC's `registrationDate`.
// A company is treated as "first year" when registered less than 12 months
// ago: fits BFL's 6-18 month opening-period window comfortably. Returns
// both the toggle state and a seeded `first_year_start` (always the 1st of
// the registration month, the format Step 3's date inputs expect).
function deriveFirstYearDefaults(registrationDate: number | null | undefined): {
isFirstFiscalYear: boolean
firstYearStart: string | undefined
} {
if (!registrationDate || !Number.isFinite(registrationDate)) {
return { isFirstFiscalYear: false, firstYearStart: undefined }
}
const regDate = new Date(registrationDate)
if (Number.isNaN(regDate.getTime())) {
return { isFirstFiscalYear: false, firstYearStart: undefined }
}
const monthsAgo =
(Date.now() - regDate.getTime()) / (1000 * 60 * 60 * 24 * 30.44)
if (monthsAgo >= 12) return { isFirstFiscalYear: false, firstYearStart: undefined }
const year = regDate.getUTCFullYear()
const month = String(regDate.getUTCMonth() + 1).padStart(2, '0')
return { isFirstFiscalYear: true, firstYearStart: `${year}-${month}-01` }
}
interface WelcomeOnboardingProps {
firstName?: string | null
teamId: string
skipWelcome?: boolean
hasExistingCompanies?: boolean
/** Pre-fill Step 2 org_number when the picker routed here via ?org_number=. */
initialOrgNumber?: string
/** Mapped from a TIC `legalEntityType`. Pre-selects Step 1's radio. */
initialEntityType?: EntityType
/** Legal name from CompanyRoles. Pre-fills Step 2's company_name field. */
initialLegalName?: string
/** Set when the orgnr came from BankID CompanyRoles. Step 2 treats the
* field as pre-verified: skips the client-side Lens `/lookup` since
* CompanyRoles already confirms the company exists. Cleared the moment
* the user edits the orgnr. */
preverifiedOrgNumber?: string
}
export default function WelcomeOnboarding({
firstName,
teamId,
skipWelcome,
hasExistingCompanies,
initialOrgNumber,
initialEntityType,
initialLegalName,
preverifiedOrgNumber,
}: WelcomeOnboardingProps) {
const router = useRouter()
const { toast } = useToast()
const t = useTranslations('onboarding')
const STEP_INFO = buildStepInfo(t)
const [started, setStarted] = useState(skipWelcome ?? false)
const [isSaving, setIsSaving] = useState(false)
const [currentStep, setCurrentStep] = useState(1)
// Seed settings with what BankID CompanyRoles already told us. address /
// F-skatt / VAT come later: user types address in Step 2 and confirms
// F-skatt/VAT in Step 4. We deliberately don't pre-fetch from Lens to
// preserve the 3000/mo budget for the manual-orgnr path.
const [settings, setSettings] = useState<Partial<CompanySettings>>(() => {
const seed: Partial<CompanySettings> = {}
if (initialOrgNumber) seed.org_number = initialOrgNumber
if (initialEntityType) seed.entity_type = initialEntityType
if (initialLegalName) seed.company_name = initialLegalName
return seed
})
const ticEnabled = ENABLED_EXTENSION_IDS.has('tic')
const [ticLookup, setTicLookup] = useState<CompanyLookupResult | null>(null)
const totalSteps = 4
const hour = new Date().getHours()
const greeting = hour < 5 ? t('greeting_night') : hour < 10 ? t('greeting_morning') : hour < 14 ? t('greeting_hello') : hour < 18 ? t('greeting_afternoon') : t('greeting_evening')
const handleNext = async (stepData: Partial<CompanySettings>) => {
// Reset org_number/company_name only on a genuine change (user going back
// and picking a different entity type). First-time selection must not
// wipe a pre-fill (e.g. ?org_number= deep-link from /select-company).
if (
currentStep === 1 &&
stepData.entity_type &&
settings.entity_type &&
stepData.entity_type !== settings.entity_type
) {
stepData = { ...stepData, org_number: '', company_name: '' }
setTicLookup(null)
}
const mergedSettings = { ...settings, ...stepData }
// Validate fiscal period at step 3 before advancing
if (currentStep === 3) {
const periodResult = computeFiscalPeriod(mergedSettings)
if (periodResult.error) {
toast({
title: t('toast_invalid_fiscal_year'),
description: translatePeriodError(periodResult.error, t),
variant: 'destructive',
})
return
}
}
// Steps 1-3: collect data client-side only, advance step
if (currentStep < totalSteps) {
setSettings(mergedSettings)
setCurrentStep(currentStep + 1)
return
}
// Step 4 (final): create everything via server action.
// Going through a server action ensures that if the Next.js server is
// unreachable, nothing touches Supabase: no ghost companies.
const periodResult = computeFiscalPeriod(mergedSettings)
if (periodResult.error) {
toast({
title: t('toast_invalid_fiscal_year'),
description: translatePeriodError(periodResult.error, t),
variant: 'destructive',
})
return
}
setIsSaving(true)
try {
const result = await createCompanyFromOnboarding({
teamId,
settings: mergedSettings as Record<string, unknown>,
fiscalPeriod: {
startDate: periodResult.startStr,
endDate: periodResult.endStr,
name: periodResult.periodName,
},
ticLookup,
})
if (result.error || !result.companyId) {
logError('create company action failed', { error: result.error })
let title = t('toast_error_title')
let description: string = result.error || t('toast_create_failed')
let backToStep2 = false
if (result.error === 'org_number_invalid') {
title = t('toast_org_invalid_title')
description = t('toast_org_invalid_description')
backToStep2 = true
}
toast({
title,
description,
variant: 'destructive',
})
// Back user up to step 2 so they can correct the org number.
if (backToStep2) {
setCurrentStep(2)
}
return
}
toast({
title: t('toast_welcome_title'),
description: t('toast_company_ready'),
})
router.push('/')
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
logError('create company action threw', { error: message })
toast({ title: t('toast_unexpected_error'), variant: 'destructive' })
} finally {
setIsSaving(false)
}
}
const handleBack = () => {
if (currentStep > 1) {
setCurrentStep(currentStep - 1)
}
}
const stepInfo = STEP_INFO[currentStep - 1]
// Welcome screen: show before user clicks "Lägg till ditt första företag"
if (!started) {
return (
<div className="flex flex-col items-start justify-center min-h-[60vh] animate-fade-in">
<p className="text-muted-foreground/50 text-sm mb-2">{greeting}</p>
<h1 className="font-display text-4xl md:text-5xl tracking-tight leading-[1.05] mb-10">
{t('welcome_title', { appName: branding.appName })}
</h1>
<button
onClick={() => setStarted(true)}
className="px-4 py-2 rounded-lg bg-foreground text-background text-sm font-medium hover:bg-foreground/85 transition-colors duration-150"
>
{hasExistingCompanies ? t('add_a_company') : t('add_first_company')}
</button>
</div>
)
}
return (
<div className="stagger-enter">
{/* Greeting header */}
<header className="mb-10">
<h1 className="font-display text-2xl md:text-3xl tracking-tight">
{greeting}{firstName ? `, ${firstName}` : ''}
</h1>
<p className="text-muted-foreground text-sm mt-1.5">
{hasExistingCompanies ? t('add_company_subtitle') : t('add_first_company_subtitle')}
</p>
</header>
{/* Onboarding card */}
<div className="max-w-lg">
<div className="rounded-xl border bg-card overflow-hidden" style={{ boxShadow: 'var(--shadow-md)' }}>
{/* Card header with step info */}
<div className="bg-[#141414] text-white px-6 py-5 relative overflow-hidden">
<div className="absolute inset-0 pointer-events-none" aria-hidden>
<div
className="absolute inset-0"
style={{
background: 'radial-gradient(ellipse at 30% -20%, rgba(255,255,255,0.04) 0%, transparent 50%)',
}}
/>
{/* Step instrument, ghosted in white ink. Keyed by step so it
crossfades together with the form below. */}
{(() => {
const art = STEP_ART[currentStep - 1]
const dims = ILLUSTRATIONS[art.name]
return (
<div
key={`art-${currentStep}`}
className={cn('absolute animate-fade-in', art.className)}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={illustrationSrc(art.name)}
width={dims.w}
height={dims.h}
alt=""
loading="eager"
decoding="async"
className="block h-auto w-full opacity-30 [filter:grayscale(1)_invert(1)]"
/>
</div>
)
})()}
</div>
<div className="relative z-10">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<Building2 className="h-4 w-4 text-white/60" />
<span className="text-xs text-white/40 tracking-wide uppercase">{t('new_company')}</span>
</div>
<div className="flex items-center gap-1.5">
{STEP_INFO.map((_, i) => {
const num = i + 1
return (
<div
key={i}
className={cn(
'h-[3px] rounded-full transition-all duration-500',
num === currentStep && 'w-6 bg-white',
num < currentStep && 'w-3 bg-white/50',
num > currentStep && 'w-3 bg-white/[0.1]',
)}
/>
)
})}
<span className="text-[10px] text-white/30 ml-1.5">
{currentStep}/{totalSteps}
</span>
</div>
</div>
<h2 className="font-display text-lg tracking-tight leading-tight">
{stepInfo.title}
</h2>
<p className="text-white/40 mt-1 text-sm">
{stepInfo.subtitle}
</p>
</div>
</div>
{/* Form content */}
<div className="px-6 py-6">
<div key={`step-${currentStep}`} className="animate-slide-up">
{currentStep === 1 && (
<Step1EntityType
initialData={{ entity_type: settings.entity_type as EntityType }}
onNext={(data) => handleNext(data)}
isSaving={isSaving}
/>
)}
{currentStep === 2 && (
<Step2CompanyDetails
key={settings.entity_type}
initialData={{
company_name: settings.company_name ?? undefined,
org_number: settings.org_number ?? undefined,
address_line1: settings.address_line1 ?? undefined,
postal_code: settings.postal_code ?? undefined,
city: settings.city ?? undefined,
}}
entityType={settings.entity_type as EntityType}
ticEnabled={ticEnabled}
onTicLookup={setTicLookup}
preverifiedOrgNumber={preverifiedOrgNumber}
onNext={(data) => handleNext(data)}
onBack={handleBack}
isSaving={isSaving}
/>
)}
{currentStep === 3 && (() => {
// Derive both first-year defaults from TIC's registrationDate
// (null-safe: returns { false, undefined } for established
// companies and for missing registrationDate).
const firstYearDefaults = deriveFirstYearDefaults(
ticLookup?.registrationDate,
)
return (
<Step3TaxRegistration
initialData={{
f_skatt: settings.f_skatt ?? (ticLookup ? ticLookup.registration.fTax : undefined),
// Prefer the user's previously-saved value, then fall back
// to TIC's v2 `fiscalYear.startMonthDay` (e.g. "07-01" →
// start month 7). Parsing the MM-DD lets us skip the
// manual end-month picker for the ~95% of companies that
// have a registered fiscal year in Bolagsverket.
fiscal_year_start_month:
settings.fiscal_year_start_month
?? parseStartMonthDay(ticLookup?.fiscalYear?.startMonthDay)
?? undefined,
// Companies registered <12 months ago land in their first
// fiscal year: pre-check the toggle and seed the start
// date so the user only confirms the end date.
is_first_fiscal_year:
settings.is_first_fiscal_year ?? firstYearDefaults.isFirstFiscalYear,
first_year_start:
settings.first_year_start ?? firstYearDefaults.firstYearStart,
}}
entityType={settings.entity_type as EntityType}
onNext={(data) => handleNext(data)}
onBack={handleBack}
isSaving={isSaving}
/>
)
})()}
{currentStep === 4 && (
<Step4VatAccounting
initialData={{
vat_registered: settings.vat_registered ?? (ticLookup ? ticLookup.registration.vat : undefined),
vat_number: settings.vat_number ?? undefined,
moms_period: (settings.moms_period as MomsPeriod | null) ?? undefined,
accounting_method: (settings.accounting_method as 'accrual' | 'cash') ?? undefined,
}}
entityType={settings.entity_type as EntityType}
orgNumber={settings.org_number ?? undefined}
onNext={(data) => handleNext(data)}
onBack={handleBack}
isSaving={isSaving}
/>
)}
</div>
</div>
</div>
</div>
</div>
)
}