'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, type OrganizationMember, } 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 { API_URL, APP_URL } from '@/lib/api/client' import { integrations, getIntegration } from '@/lib/integrations' import { trackWelcomeStepView, trackWelcomeWorkspaceSelected, trackWelcomeWorkspaceCreated, trackWelcomePlanContinue, trackWelcomePlanSkip, trackWelcomeSiteAdded, trackWelcomeSiteSkipped, trackWelcomeCompleted, } from '@/lib/welcomeAnalytics' import { LoadingOverlay, Button, Input } from '@ciphera-net/ui' import { toast } from '@ciphera-net/ui' import { CheckCircleIcon, CheckIcon, ArrowRightIcon, ArrowLeftIcon, BarChartIcon, GlobeIcon, ZapIcon, PlusIcon, } from '@ciphera-net/ui' import Link from 'next/link' const TOTAL_STEPS = 5 const DEFAULT_ORG_NAME = 'My workspace' const SITE_DRAFT_KEY = 'pulse_welcome_site_draft' const WELCOME_COMPLETED_KEY = 'pulse_welcome_completed' function slugFromName(name: string): string { return name.toLowerCase().replace(/[^a-z0-9]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '') || 'my-workspace' } function suggestSlugVariant(slug: string): string { const m = slug.match(/^(.+?)(-\d+)?$/) if (!m) return `${slug}-2` const base = m[1] const num = m[2] ? parseInt(m[2].slice(1), 10) : 0 return `${base}-${num + 2}` } function getOrgErrorMessage(err: unknown, currentSlug: string, fallback: string): { message: string; suggestSlug?: string } { const apiErr = err as { data?: { message?: string }; message?: string } const raw = apiErr?.data?.message || apiErr?.message || '' if (/slug|already|taken|duplicate|exists/i.test(raw)) { return { message: 'This URL slug is already in use. Try a different one.', suggestSlug: suggestSlugVariant(currentSlug), } } return { message: getAuthErrorMessage(err) || (err as Error)?.message || fallback } } 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 [organizations, setOrganizations] = useState(null) const [orgsLoading, setOrgsLoading] = useState(false) const [switchingOrgId, setSwitchingOrgId] = useState(null) const [selectedIntegrationSlug, setSelectedIntegrationSlug] = useState(null) const [scriptCopied, setScriptCopied] = 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]) // * Fetch organizations when on step 1 so we can show "Choose workspace" when user has orgs useEffect(() => { if (!user || step !== 1) return let cancelled = false setOrgsLoading(true) getUserOrganizations() .then((orgs) => { if (!cancelled) setOrganizations(orgs || []) }) .catch(() => { if (!cancelled) setOrganizations([]) }) .finally(() => { if (!cancelled) setOrgsLoading(false) }) return () => { cancelled = true } }, [user, step]) const handleSelectWorkspace = async (org: OrganizationMember) => { setSwitchingOrgId(org.organization_id) try { const { access_token } = await switchContext(org.organization_id) const result = await setSessionAction(access_token) if (result.success && result.user) { login(result.user) router.refresh() trackWelcomeWorkspaceSelected() setStep(3) } } catch (err) { toast.error(getAuthErrorMessage(err) || 'Failed to switch workspace') } finally { setSwitchingOrgId(null) } } const handleCreateNewWorkspace = () => setStep(2) 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() } trackWelcomeWorkspaceCreated(!!(typeof window !== 'undefined' && localStorage.getItem('pulse_pending_checkout'))) setStep(3) } catch (err: unknown) { const { message, suggestSlug } = getOrgErrorMessage(err, orgSlug, 'Failed to create workspace') setOrgError(message) if (suggestSlug) setOrgSlug(suggestSlug) } finally { setOrgLoading(false) } } const handlePlanContinue = async () => { const raw = localStorage.getItem('pulse_pending_checkout') if (!raw) { setStep(4) return } setPlanLoading(true) setPlanError('') try { trackWelcomePlanContinue() 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 = () => { trackWelcomePlanSkip() 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) if (typeof window !== 'undefined') sessionStorage.removeItem(SITE_DRAFT_KEY) trackWelcomeSiteAdded() 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 = () => { trackWelcomeSiteSkipped() if (typeof window !== 'undefined') sessionStorage.removeItem(SITE_DRAFT_KEY) setStep(5) } const goToDashboard = () => { if (typeof window !== 'undefined') localStorage.setItem(WELCOME_COMPLETED_KEY, 'true') trackWelcomeCompleted(!!createdSite) router.push('/') } const goToSite = () => createdSite && router.push(`/sites/${createdSite.id}`) const copyScript = useCallback(() => { if (!createdSite) return const script = `` navigator.clipboard.writeText(script) setScriptCopied(true) toast.success('Script copied to clipboard') setTimeout(() => setScriptCopied(false), 2000) }, [createdSite]) const popularIntegrations = integrations.filter((i) => i.category === 'framework').slice(0, 10) const showPendingCheckoutInStep3 = hadPendingCheckout === true && !dismissedPendingCheckout useEffect(() => { if (step === 3 && hadPendingCheckout === null && typeof window !== 'undefined') { setHadPendingCheckout(!!localStorage.getItem('pulse_pending_checkout')) } }, [step, hadPendingCheckout]) useEffect(() => { trackWelcomeStepView(step) }, [step]) // * Restore first-site draft from sessionStorage useEffect(() => { if (step !== 4 || typeof window === 'undefined') return try { const raw = sessionStorage.getItem(SITE_DRAFT_KEY) if (raw) { const d = JSON.parse(raw) as { name?: string; domain?: string } if (d.name) setSiteName(d.name) if (d.domain) setSiteDomain(d.domain) } } catch { // ignore } }, [step]) // * Persist first-site draft to sessionStorage useEffect(() => { if (step !== 4 || typeof window === 'undefined') return sessionStorage.setItem(SITE_DRAFT_KEY, JSON.stringify({ name: siteName, domain: siteDomain })) }, [step, siteName, siteDomain]) if (orgLoading && step === 2) { return } if (switchingOrgId) { 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 && ( {orgsLoading ? (

Loading your workspaces...

) : organizations && organizations.length > 0 ? ( <>

Choose your workspace

Continue with an existing workspace or create a new one.

{organizations.map((org) => ( ))}
) : (

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 ? ( <> ) : ( <>

View pricing

)}
{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 && (

Add the script to your site

Choose your framework for setup instructions.

{popularIntegrations.map((int) => ( ))}

View all integrations →

{``}
{selectedIntegrationSlug && getIntegration(selectedIntegrationSlug) && (

See full {getIntegration(selectedIntegrationSlug)!.name} guide →

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