'use client' /** * Guided onboarding wizard for new Pulse users. * Steps: Welcome → Workspace (create org) → Plan / trial → First site (optional) → Done. * Supports ?step= in URL for back/refresh. Handles pulse_pending_checkout from pricing. */ import { useState, useEffect, useCallback, Suspense } from 'react' import { useRouter, useSearchParams } from 'next/navigation' import { motion, AnimatePresence } from 'framer-motion' import { createOrganization, getUserOrganizations, switchContext, type Organization, } from '@/lib/api/organization' import { createCheckoutSession } from '@/lib/api/billing' import { createSite, type Site } from '@/lib/api/sites' import { setSessionAction } from '@/app/actions/auth' import { useAuth } from '@/lib/auth/context' import { getAuthErrorMessage } from '@/lib/utils/authErrors' import { LoadingOverlay, Button, Input } from '@ciphera-net/ui' import { toast } from '@ciphera-net/ui' import { CheckCircleIcon, ArrowRightIcon, BarChartIcon, GlobeIcon, ZapIcon, } from '@ciphera-net/ui' const TOTAL_STEPS = 5 const DEFAULT_ORG_NAME = 'My workspace' function slugFromName(name: string): string { return name.toLowerCase().replace(/[^a-z0-9]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '') || 'my-workspace' } function WelcomeContent() { const router = useRouter() const searchParams = useSearchParams() const { user, login } = useAuth() const stepParam = searchParams.get('step') const stepFromUrl = stepParam ? Math.min(Math.max(1, parseInt(stepParam, 10)), TOTAL_STEPS) : 1 const [step, setStepState] = useState(stepFromUrl) const [orgName, setOrgName] = useState(DEFAULT_ORG_NAME) const [orgSlug, setOrgSlug] = useState(slugFromName(DEFAULT_ORG_NAME)) const [orgLoading, setOrgLoading] = useState(false) const [orgError, setOrgError] = useState('') const [planLoading, setPlanLoading] = useState(false) const [planError, setPlanError] = useState('') const [siteName, setSiteName] = useState('') const [siteDomain, setSiteDomain] = useState('') const [siteLoading, setSiteLoading] = useState(false) const [siteError, setSiteError] = useState('') const [createdSite, setCreatedSite] = useState(null) const [redirectingCheckout, setRedirectingCheckout] = useState(false) const [hadPendingCheckout, setHadPendingCheckout] = useState(null) const [dismissedPendingCheckout, setDismissedPendingCheckout] = useState(false) const setStep = useCallback( (next: number) => { const s = Math.min(Math.max(1, next), TOTAL_STEPS) setStepState(s) const url = new URL(window.location.href) url.searchParams.set('step', String(s)) window.history.replaceState({}, '', url.pathname + url.search) }, [] ) useEffect(() => { const stepFromUrl = stepParam ? Math.min(Math.max(1, parseInt(stepParam, 10)), TOTAL_STEPS) : 1 if (stepFromUrl !== step) setStepState(stepFromUrl) }, [stepParam, step]) // * If user already has orgs and no pending checkout, send to dashboard (avoid re-doing wizard) useEffect(() => { if (!user || step !== 1) return let cancelled = false getUserOrganizations() .then((orgs) => { if (cancelled || orgs.length === 0) return if (!localStorage.getItem('pulse_pending_checkout')) { router.replace('/') } }) .catch(() => {}) return () => { cancelled = true } }, [user, step, router]) const handleNameChange = (e: React.ChangeEvent) => { const val = e.target.value setOrgName(val) setOrgSlug((prev) => prev === slugFromName(orgName) ? slugFromName(val) : prev ) } const handleWorkspaceSubmit = async (e: React.FormEvent) => { e.preventDefault() setOrgLoading(true) setOrgError('') try { const org = await createOrganization(orgName.trim(), orgSlug.trim()) const { access_token } = await switchContext(org.id) const result = await setSessionAction(access_token) if (result.success && result.user) { login(result.user) router.refresh() } setStep(3) } catch (err: unknown) { setOrgError(getAuthErrorMessage(err) || (err as Error)?.message || 'Failed to create workspace') } finally { setOrgLoading(false) } } const handlePlanContinue = async () => { const raw = localStorage.getItem('pulse_pending_checkout') if (!raw) { setStep(4) return } setPlanLoading(true) setPlanError('') try { const intent = JSON.parse(raw) const { url } = await createCheckoutSession({ plan_id: intent.planId, interval: intent.interval || 'month', limit: intent.limit ?? 100000, }) localStorage.removeItem('pulse_pending_checkout') if (url) { setRedirectingCheckout(true) window.location.href = url return } throw new Error('No checkout URL returned') } catch (err: unknown) { setPlanError(getAuthErrorMessage(err) || (err as Error)?.message || 'Failed to start checkout') localStorage.removeItem('pulse_pending_checkout') } finally { setPlanLoading(false) } } const handlePlanSkip = () => { localStorage.removeItem('pulse_pending_checkout') setDismissedPendingCheckout(true) setStep(4) } const handleAddSite = async (e: React.FormEvent) => { e.preventDefault() if (!siteName.trim() || !siteDomain.trim()) return setSiteLoading(true) setSiteError('') try { const site = await createSite({ name: siteName.trim(), domain: siteDomain.trim().toLowerCase(), }) setCreatedSite(site) toast.success('Site added') setStep(5) } catch (err: unknown) { setSiteError(getAuthErrorMessage(err) || (err as Error)?.message || 'Failed to add site') } finally { setSiteLoading(false) } } const handleSkipSite = () => setStep(5) const goToDashboard = () => router.push('/') const goToSite = () => createdSite && router.push(`/sites/${createdSite.id}`) const showPendingCheckoutInStep3 = hadPendingCheckout === true && !dismissedPendingCheckout useEffect(() => { if (step === 3 && hadPendingCheckout === null && typeof window !== 'undefined') { setHadPendingCheckout(!!localStorage.getItem('pulse_pending_checkout')) } }, [step, hadPendingCheckout]) if (orgLoading && step === 2) { return } if (redirectingCheckout || (planLoading && step === 3)) { return ( ) } const cardClass = 'bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl shadow-sm p-8 max-w-lg mx-auto' return (
{Array.from({ length: TOTAL_STEPS }, (_, i) => (
))}
{step === 1 && (

Welcome to Pulse

Privacy-first analytics in a few steps. No credit card required to start.

)} {step === 2 && (

Name your workspace

You can change this later in settings.

setOrgSlug(e.target.value)} className="w-full" />

Used in your workspace URL.

{orgError && (

{orgError}

)}
)} {step === 3 && (

{showPendingCheckoutInStep3 ? 'Complete your plan' : "You're on the free plan"}

{showPendingCheckoutInStep3 ? 'You chose a plan on the pricing page. Continue to add a payment method and start your trial.' : 'Start with 1 site and 10k pageviews/month. Upgrade anytime from your dashboard.'}

{planError && (

{planError}

)}
{showPendingCheckoutInStep3 ? ( <> ) : ( )}
{showPendingCheckoutInStep3 && (

)}
)} {step === 4 && (

Add your first site

Optional. You can add sites later from the dashboard.

setSiteName(e.target.value)} className="w-full" />
setSiteDomain(e.target.value.toLowerCase().trim())} className="w-full" />

Without http:// or https://

{siteError && (

{siteError}

)}
)} {step === 5 && (

You're all set

{createdSite ? `"${createdSite.name}" is ready. Add the script to your site to start collecting data.` : 'Head to your dashboard to add sites and view analytics.'}

{createdSite && ( )}
)}
) } export default function WelcomePage() { return ( }> ) }