From c715bc4ce424be77a11263dd6edcb34eb187b901 Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Sun, 8 Feb 2026 13:53:54 +0100 Subject: [PATCH] refactor: update routing logic to redirect to '/welcome' after auth callback and handle organization checks --- app/auth/callback/page.tsx | 2 +- app/welcome/page.tsx | 519 +++++++++++++++++++++++++++++++++++++ lib/auth/context.tsx | 9 +- 3 files changed, 523 insertions(+), 7 deletions(-) create mode 100644 app/welcome/page.tsx diff --git a/app/auth/callback/page.tsx b/app/auth/callback/page.tsx index db02ff4..0537e22 100644 --- a/app/auth/callback/page.tsx +++ b/app/auth/callback/page.tsx @@ -27,7 +27,7 @@ function AuthCallbackContent() { localStorage.removeItem('oauth_state') localStorage.removeItem('oauth_code_verifier') if (localStorage.getItem('pulse_pending_checkout')) { - router.push('/pricing') + router.push('/welcome') } else { router.push('/') } diff --git a/app/welcome/page.tsx b/app/welcome/page.tsx new file mode 100644 index 0000000..42e8363 --- /dev/null +++ b/app/welcome/page.tsx @@ -0,0 +1,519 @@ +'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 ( + }> + + + ) +} diff --git a/lib/auth/context.tsx b/lib/auth/context.tsx index fc9fdad..bf4bcd4 100644 --- a/lib/auth/context.tsx +++ b/lib/auth/context.tsx @@ -130,18 +130,15 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { useEffect(() => { const checkOrg = async () => { if (!loading && user) { - // * If we are on onboarding, skip check if (pathname?.startsWith('/onboarding')) return - - // * If we are processing auth callback, skip check to avoid redirect loops if (pathname?.startsWith('/auth/callback')) return try { const organizations = await getUserOrganizations() - + if (organizations.length === 0) { - // * No organizations -> Redirect to Onboarding - router.push('/onboarding') + if (pathname?.startsWith('/welcome')) return + router.push('/welcome') return }