'use client' /** * Guided onboarding wizard for new Pulse users. * Steps: Welcome → Organization (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 apiRequest from '@/lib/api/client' import { getAuthErrorMessage } from '@ciphera-net/ui' 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, ArrowRightIcon, ArrowLeftIcon, BarChartIcon, PlusIcon, } from '@ciphera-net/ui' import Link from 'next/link' import ScriptSetupBlock from '@/components/sites/ScriptSetupBlock' import VerificationModal from '@/components/sites/VerificationModal' const TOTAL_STEPS = 5 const DEFAULT_ORG_NAME = 'My organization' 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-organization' } 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 [showVerificationModal, setShowVerificationModal] = useState(false) 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 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 handleSelectOrganization = 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) { try { const fullProfile = await apiRequest<{ id: string; email: string; display_name?: string; totp_enabled: boolean; org_id?: string; role?: string }>('/auth/user/me') const merged = { ...fullProfile, org_id: result.user.org_id ?? fullProfile.org_id, role: result.user.role ?? fullProfile.role } login(merged) } catch { login(result.user) } router.refresh() trackWelcomeWorkspaceSelected() setStep(3) } } catch (err) { toast.error(getAuthErrorMessage(err) || 'Failed to switch workspace') } finally { setSwitchingOrgId(null) } } const handleCreateNewOrganization = () => setStep(2) const handleNameChange = (e: React.ChangeEvent) => { const val = e.target.value setOrgName(val) setOrgSlug((prev) => prev === slugFromName(orgName) ? slugFromName(val) : prev ) } const handleOrganizationSubmit = 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) { try { const fullProfile = await apiRequest<{ id: string; email: string; display_name?: string; totp_enabled: boolean; org_id?: string; role?: string }>('/auth/user/me') const merged = { ...fullProfile, org_id: result.user.org_id ?? fullProfile.org_id, role: result.user.role ?? fullProfile.role } login(merged) } catch { 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 organization') 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 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-6 max-w-lg mx-auto' return (
{Array.from({ length: TOTAL_STEPS }, (_, i) => (
))}
{step === 1 && ( {orgsLoading ? (

Loading your organizations...

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

Choose your organization

Continue with an existing one or create a new organization.

{organizations.map((org, index) => { const isCurrent = user?.org_id === org.organization_id const initial = (org.organization_name || 'O').charAt(0).toUpperCase() return ( handleSelectOrganization(org)} disabled={!!switchingOrgId} initial={{ opacity: 0, y: 8 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: index * 0.04, duration: 0.2 }} className={`w-full flex items-center gap-3 rounded-xl border px-4 py-3.5 text-left transition-all duration-200 disabled:opacity-60 ${ isCurrent ? 'border-brand-orange/60 bg-brand-orange/5 dark:bg-brand-orange/10 shadow-sm' : 'border-neutral-200 dark:border-neutral-700 bg-neutral-50/80 dark:bg-neutral-800/50 hover:bg-neutral-100 dark:hover:bg-neutral-800 hover:border-neutral-300 dark:hover:border-neutral-600 hover:shadow-sm' }`} >
{initial}
{org.organization_name || 'Organization'} {isCurrent && ( Current )}
) })}
) : (
Welcome to Pulse

Welcome to Pulse

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

)} )} {step === 2 && (

Name your organization

You can change this later in settings.

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

Used in your organization 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 ? (

) : (

View pricing

)}
)} {step === 4 && (
Add your first site

Add your first site

Optional. You can add sites later from the dashboard.

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

Without http:// or https://

{siteError && (

{siteError}

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

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

Check if your site is sending data correctly.

)}
{createdSite && ( )}
{createdSite && ( setShowVerificationModal(false)} site={createdSite} /> )}
)}
) } export default function WelcomePage() { return ( }> ) }