feat(onboarding): branch question on the journey done screen (#1468)
* feat(onboarding): branch question on the journey done screen Second slice of the approved activation concept: the moment the company exists, the done screen asks "Var fanns bokföringen innan?" with provider chips (real logos), SIE file, and new-business options, plus a quiet look-around escape. Choices persist initial_setup_path (fire-and-forget) and deep-link into the existing flows: providers jump straight to the migration wizard's connect step (sieViaApi providers only; Visma/Bokio land on the provider list where the SIE-first gate lives), the SIE chip opens the upload step, new business lands on Hem with step one checked off. mode='add' keeps the plain "Öppna Accounted" button: the concept's own guard, and it avoids writing the path onto the previous company if setActiveCompany silently failed. Routing lives in a pure helper with tests; anonymous onboarding_branch_chosen funnel event follows the guarded capture pattern. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(onboarding): review triage: single-choice latch, preselect reset Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: restore package-lock.json to main (worktree npm install mutated it) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
Jakob Wennberg
parent
44cff5e5e4
commit
c7dd78b0a3
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useCallback, useEffect } from 'react'
|
||||
import { useState, useCallback, useEffect, useRef } from 'react'
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
@@ -1799,7 +1799,13 @@ function EntityResultRow({
|
||||
|
||||
// ── Main wizard ─────────────────────────────────────────────────
|
||||
|
||||
export default function ArcimMigrationWorkspace(_props: WorkspaceComponentProps) {
|
||||
export default function ArcimMigrationWorkspace({
|
||||
initialProvider,
|
||||
}: WorkspaceComponentProps & {
|
||||
/** Deep-linked old system (onboarding branch question): jump straight to
|
||||
* its connect step instead of showing the provider list. */
|
||||
initialProvider?: string
|
||||
}) {
|
||||
const { toast } = useToast()
|
||||
|
||||
const [step, setStep] = useState<WizardStep>('provider')
|
||||
@@ -2129,6 +2135,22 @@ export default function ArcimMigrationWorkspace(_props: WorkspaceComponentProps)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
// Deep-linked provider preselect (onboarding branch question). Only when
|
||||
// this mount is not an OAuth return (that flow owns the wizard state), and
|
||||
// only for providers whose SIE comes via API: visma/bokio must first see
|
||||
// the provider list with its "SIE krävs först" gate, which depends on
|
||||
// async connection status.
|
||||
const preselectedRef = useRef(false)
|
||||
useEffect(() => {
|
||||
if (preselectedRef.current || !initialProvider) return
|
||||
if (new URL(window.location.href).searchParams.get('migration')) return
|
||||
const provider = ARCIM_PROVIDERS.find((p) => p.id === initialProvider)
|
||||
if (!provider || COMING_SOON_PROVIDERS.has(provider.id) || !provider.sieViaApi) return
|
||||
preselectedRef.current = true
|
||||
void handleSelectProvider(provider.id)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [initialProvider])
|
||||
|
||||
// Listen for postMessage from OAuth popup
|
||||
useEffect(() => {
|
||||
function handleMessage(event: MessageEvent) {
|
||||
|
||||
@@ -11,6 +11,13 @@ 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 posthog from 'posthog-js'
|
||||
import { isAnalyticsEnabled } from '@/lib/analytics/enabled'
|
||||
import {
|
||||
BRANCH_PROVIDERS,
|
||||
branchDestination,
|
||||
type BranchChoice,
|
||||
} from '@/lib/onboarding-journey/branch'
|
||||
import {
|
||||
initJourney,
|
||||
journeyReducer,
|
||||
@@ -55,6 +62,20 @@ function logError(message: string, extra?: Record<string, unknown>) {
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
/**
|
||||
* Branch-question funnel event. Anonymous by design: AnalyticsIdentify only
|
||||
* mounts in the dashboard layout, so this measures choice distribution, not
|
||||
* people. Guarded + swallowed like every product capture.
|
||||
*/
|
||||
function captureBranch(choice: BranchChoice) {
|
||||
if (!isAnalyticsEnabled()) return
|
||||
try {
|
||||
posthog.capture('onboarding_branch_chosen', { choice })
|
||||
} catch {
|
||||
// Telemetry must never affect the journey.
|
||||
}
|
||||
}
|
||||
|
||||
interface OnboardingJourneyProps {
|
||||
teamId: string
|
||||
mode?: 'first' | 'add'
|
||||
@@ -87,6 +108,8 @@ export default function OnboardingJourney({
|
||||
)
|
||||
|
||||
const bandRef = useRef<HTMLDivElement | null>(null)
|
||||
// Latches after the first done-screen branch choice (see onBranch below).
|
||||
const branchChosenRef = useRef(false)
|
||||
const [orgInput, setOrgInput] = useState(initialOrgNumber ?? '')
|
||||
const [orgShake, setOrgShake] = useState(false)
|
||||
const [thinking, setThinking] = useState(false)
|
||||
@@ -634,10 +657,32 @@ export default function OnboardingJourney({
|
||||
<DoneStep
|
||||
t={t}
|
||||
state={state}
|
||||
mode={mode}
|
||||
fyAnswer={fyAnswer}
|
||||
momsAnswer={momsAnswer}
|
||||
methodAnswer={methodAnswer}
|
||||
onOpen={() => router.push('/')}
|
||||
onBranch={(choice) => {
|
||||
// One choice only: rapid clicks on different chips must not
|
||||
// race two PATCHes (last-write-wins could persist the wrong
|
||||
// path after navigation).
|
||||
if (branchChosenRef.current) return
|
||||
branchChosenRef.current = true
|
||||
const dest = branchDestination(choice)
|
||||
if (dest.path) {
|
||||
// Fire-and-forget: the checklist path is a nicety, routing is
|
||||
// the point. A lost PATCH just leaves the Hem checklist
|
||||
// unpathed; it must never block or delay the navigation.
|
||||
fetch('/api/onboarding/state', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ path: dest.path }),
|
||||
keepalive: true,
|
||||
}).catch(() => logError('branch path persist failed', { choice }))
|
||||
}
|
||||
captureBranch(choice)
|
||||
router.push(dest.href)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -957,17 +1002,21 @@ function FyEndStep({
|
||||
function DoneStep({
|
||||
t,
|
||||
state,
|
||||
mode,
|
||||
fyAnswer,
|
||||
momsAnswer,
|
||||
methodAnswer,
|
||||
onOpen,
|
||||
onBranch,
|
||||
}: {
|
||||
t: TFn
|
||||
state: JourneyState
|
||||
mode: 'first' | 'add'
|
||||
fyAnswer: string | null
|
||||
momsAnswer: string | null
|
||||
methodAnswer: string | null
|
||||
onOpen: () => void
|
||||
onBranch: (choice: BranchChoice) => void
|
||||
}) {
|
||||
const s = state.settings
|
||||
const shortName = (s.company_name ?? '').split(' ')[0] || ''
|
||||
@@ -1017,15 +1066,64 @@ function DoneStep({
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="jny-qactions">
|
||||
<button type="button" className="jny-btn" onClick={onOpen}>
|
||||
{t('journey_open_app')}
|
||||
</button>
|
||||
</div>
|
||||
{mode === 'first' ? (
|
||||
<Reveal delay={notes.length > 0 ? 1400 + notes.length * 260 : 1200}>
|
||||
<div className="jny-done-branch">
|
||||
<h2 className="jny-done-q">
|
||||
<InkText text={t('journey_done_source_title')} />
|
||||
</h2>
|
||||
<p className="jny-done-sub">{t('journey_done_source_sub')}</p>
|
||||
<div className="jny-chips">
|
||||
{BRANCH_PROVIDERS.map((p) => (
|
||||
<button
|
||||
key={p.id}
|
||||
type="button"
|
||||
className="jny-pick"
|
||||
onClick={() => onBranch(p.id)}
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src={p.logo} alt="" />
|
||||
{p.name}
|
||||
</button>
|
||||
))}
|
||||
<button type="button" className="jny-pick" onClick={() => onBranch('sie')}>
|
||||
{t('journey_done_source_sie')}
|
||||
</button>
|
||||
<button type="button" className="jny-pick" onClick={() => onBranch('fresh')}>
|
||||
{t('journey_done_source_fresh')}
|
||||
</button>
|
||||
</div>
|
||||
<div className="jny-qactions">
|
||||
<button type="button" className="jny-btn-quiet" onClick={() => onBranch('skip')}>
|
||||
{t('journey_done_source_skip')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Reveal>
|
||||
) : (
|
||||
<div className="jny-qactions">
|
||||
<button type="button" className="jny-btn" onClick={onOpen}>
|
||||
{t('journey_open_app')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Delayed mount so the branch question enters (with the standard .jny-qstep
|
||||
* rise) only after the profile card and notes have finished settling. */
|
||||
function Reveal({ delay, children }: { delay: number; children: React.ReactNode }) {
|
||||
const [on, setOn] = useState(false)
|
||||
useEffect(() => {
|
||||
const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||
const id = window.setTimeout(() => setOn(true), reduced ? 0 : delay)
|
||||
return () => window.clearTimeout(id)
|
||||
}, [delay])
|
||||
if (!on) return null
|
||||
return <div className="jny-qstep">{children}</div>
|
||||
}
|
||||
|
||||
function CardRow({ label, value, delay }: { label: string; value: string; delay: number }) {
|
||||
const [on, setOn] = useState(false)
|
||||
useEffect(() => {
|
||||
|
||||
@@ -269,3 +269,12 @@
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.jny *, .jny *::before, .jny *::after { animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; }
|
||||
}
|
||||
|
||||
/* ── done-screen branch question ─────────────────────────────────── */
|
||||
.jny-done-branch { margin-top: 34px; }
|
||||
.jny-done-q { font-family: var(--font-display); font-size: 20px; font-weight: 400; margin: 0 0 4px; letter-spacing: -0.01em; }
|
||||
.jny-done-sub { font-size: 12.5px; color: hsl(var(--muted-foreground)); margin: 0 0 18px; }
|
||||
.jny-pick img { width: 16px; height: 16px; object-fit: contain; border-radius: 4px; background: #fff; vertical-align: -3px; margin-right: 8px; }
|
||||
@media (max-height: 780px) {
|
||||
.jny-done-branch { margin-top: 22px; }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user