'use client' import { useState, useEffect, useRef } from 'react' import { useTranslations } from 'next-intl' import { useForm } from 'react-hook-form' import { zodResolver } from '@hookform/resolvers/zod' import { z } from 'zod' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { Loader2, ArrowRight, ArrowLeft, CheckCircle2, AlertTriangle } from 'lucide-react' import type { EntityType } from '@/types' import type { CompanyLookupResult } from '@/lib/company-lookup/types' import { normalizeOrgNumber } from '@/lib/company-lookup/normalize-org-number' const schema = z.object({ company_name: z.string().min(1, 'Företagsnamn krävs'), org_number: z.string() .min(1, 'Organisationsnummer krävs') .refine( (val) => normalizeOrgNumber(val) !== null, 'Ogiltigt organisationsnummer. Kontrollera att du angett ett giltigt 10- eller 12-siffrigt organisationsnummer.', ), address_line1: z.string().optional(), postal_code: z.string().optional(), city: z.string().optional(), }) type FormData = z.infer interface Step2Props { initialData: Partial entityType?: EntityType ticEnabled?: boolean onTicLookup?: (result: CompanyLookupResult | null) => void onNext: (data: FormData) => void onBack: () => void isSaving: boolean orgNumberLocked?: boolean // Orgnr we already trust without a Lens call — typically because it came // from BankID CompanyRoles which confirms the user has a director role at // this company. When set and the form's orgnr matches, Step 2 skips the // debounced `/lookup` to avoid burning a Lens call on something we know // exists. The guard clears as soon as the user edits the field. preverifiedOrgNumber?: string | null } export default function Step2CompanyDetails({ initialData, entityType, ticEnabled, onTicLookup, onNext, onBack, isSaving, orgNumberLocked, preverifiedOrgNumber, }: Step2Props) { const t = useTranslations('onboarding') const { register, handleSubmit, watch, setValue, formState: { errors }, } = useForm({ resolver: zodResolver(schema), mode: 'onTouched', defaultValues: { company_name: initialData.company_name || '', org_number: initialData.org_number || '', address_line1: initialData.address_line1 || '', postal_code: initialData.postal_code || '', city: initialData.city || '', }, }) const [isLooking, setIsLooking] = useState(false) const [lookupError, setLookupError] = useState(null) const [lookupDone, setLookupDone] = useState(null) const [existingOwn, setExistingOwn] = useState<{ id: string; name: string } | null>(null) const abortRef = useRef(null) const dupAbortRef = useRef(null) // Tracks an orgnr that's been pre-verified (BankID CompanyRoles match) so // the client-side Lens lookup is skipped for that exact value. Cleared // (set to null) the moment the user edits the org number — a different // orgnr is no longer covered by the BankID confirmation and needs a real // lookup. const prefetchedForOrgRef = useRef( preverifiedOrgNumber ? normalizeOrgNumber(preverifiedOrgNumber) : null, ) const orgNumber = watch('org_number') // Soft, account-scoped duplicate warning: if THIS user already has a // (non-archived) company with the same org number, surface a non-blocking // note. Org-number reuse is allowed (see lib/company/actions.ts), so this // never disables submit. The endpoint is RLS-scoped to the caller's own // companies, so it can't reveal or count another account's. useEffect(() => { if (!orgNumber || normalizeOrgNumber(orgNumber) === null) { setExistingOwn(null) return } const timer = setTimeout(() => { dupAbortRef.current?.abort() const controller = new AbortController() dupAbortRef.current = controller fetch(`/api/company/check-org-number?org_number=${encodeURIComponent(orgNumber)}`, { signal: controller.signal, }) .then(async (res) => { if (controller.signal.aborted || !res.ok) return const { data } = await res.json() setExistingOwn(data?.companies?.[0] ?? null) }) .catch(() => { // Advisory only — never blocks creation. }) }, 500) return () => { clearTimeout(timer) dupAbortRef.current?.abort() } }, [orgNumber]) useEffect(() => { if (!ticEnabled || !orgNumber || normalizeOrgNumber(orgNumber) === null) { return } // Server already fetched this orgnr (BankID deep-link). Don't burn a // second TIC call to re-confirm what we already have in `initialLookup`. // Once the user edits the field, normalizeOrgNumber(orgNumber) will // diverge from the prefetched value and the lookup re-arms. const normalized = normalizeOrgNumber(orgNumber) if (prefetchedForOrgRef.current && normalized === prefetchedForOrgRef.current) { return } // Any subsequent edit invalidates the prefetched-match guard for good. prefetchedForOrgRef.current = null setLookupError(null) setLookupDone(null) const timer = setTimeout(() => { // Abort any in-flight request abortRef.current?.abort() const controller = new AbortController() abortRef.current = controller setIsLooking(true) fetch(`/api/extensions/ext/tic/lookup?org_number=${encodeURIComponent(orgNumber)}`, { signal: controller.signal, }) .then(async (res) => { if (controller.signal.aborted) return if (res.status === 403) { // Extension disabled — silently ignore return } if (res.status === 404) { setLookupError(t('step2_lookup_not_found')) onTicLookup?.(null) return } if (!res.ok) { setLookupError(t('step2_lookup_failed')) onTicLookup?.(null) return } const { data } = (await res.json()) as { data: CompanyLookupResult } // Guard: only apply if org_number still matches (user may have changed it) if (controller.signal.aborted) return setLookupDone(data) onTicLookup?.(data) // Auto-fill from TIC — overwrite since user just entered a new org number if (data.companyName) setValue('company_name', data.companyName) if (data.address?.street) setValue('address_line1', data.address.street) if (data.address?.postalCode) setValue('postal_code', data.address.postalCode) if (data.address?.city) setValue('city', data.address.city) }) .catch((err) => { if ((err as Error).name === 'AbortError') return setLookupError(t('step2_lookup_failed')) onTicLookup?.(null) }) .finally(() => { if (!controller.signal.aborted) setIsLooking(false) }) }, 500) return () => { clearTimeout(timer) abortRef.current?.abort() } // eslint-disable-next-line react-hooks/exhaustive-deps }, [ticEnabled, orgNumber]) const isAB = entityType === 'aktiebolag' return (
{t('step2_card_title')} {ticEnabled ? t('step2_card_desc_tic') : isAB ? t('step2_card_desc_ab') : t('step2_card_desc_ef')}
{ const fields = Object.keys(errs).join(', ') console.error('[onboarding] step 2 validation failed:', fields, errs) fetch('/api/log', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ message: 'step 2 validation failed', extra: { fields } }) }).catch(() => {}) })} className="space-y-4">
{errors.org_number && (

{errors.org_number.message}

)}

{isAB ? t('step2_org_help_ab') : t('step2_org_help_ef')}

{ticEnabled && isLooking && (
{t('step2_fetching_details')}
)} {ticEnabled && lookupDone && !lookupDone.isCeased && (
{lookupDone.companyName}
)} {ticEnabled && lookupDone?.isCeased && (
{t('step2_ceased_inline', { companyName: lookupDone.companyName })}
)} {ticEnabled && lookupError && (

{lookupError}

)} {existingOwn && (
{t('step2_company_exists_own', { name: existingOwn.name })}
)}
{errors.company_name && (

{errors.company_name.message}

)}

{t('step2_address_heading')}

) }