From c715bc4ce424be77a11263dd6edcb34eb187b901 Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Sun, 8 Feb 2026 13:53:54 +0100 Subject: [PATCH 1/9] 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 } From 58cfb6210b985788f071025846fee22225c19cf0 Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Sun, 8 Feb 2026 14:09:54 +0100 Subject: [PATCH 2/9] feat: add setup banner and site addition prompt to homepage; enhance welcome page with tracking and error handling --- app/page.tsx | 62 ++++++++++++++- app/welcome/page.tsx | 141 ++++++++++++++++++++++++++++++---- components/sites/SiteList.tsx | 7 +- lib/welcomeAnalytics.ts | 72 +++++++++++++++++ 4 files changed, 267 insertions(+), 15 deletions(-) create mode 100644 lib/welcomeAnalytics.ts diff --git a/app/page.tsx b/app/page.tsx index a620a5e..35119cb 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -10,7 +10,7 @@ import { getSubscription, type SubscriptionDetails } from '@/lib/api/billing' import { LoadingOverlay } from '@ciphera-net/ui' import SiteList from '@/components/sites/SiteList' import { Button } from '@ciphera-net/ui' -import { BarChartIcon, LockIcon, ZapIcon, CheckCircleIcon, XIcon } from '@ciphera-net/ui' +import { BarChartIcon, LockIcon, ZapIcon, CheckCircleIcon, XIcon, GlobeIcon } from '@ciphera-net/ui' import { toast } from '@ciphera-net/ui' import { getAuthErrorMessage } from '@/lib/utils/authErrors' @@ -102,6 +102,7 @@ export default function HomePage() { const [sitesLoading, setSitesLoading] = useState(true) const [subscription, setSubscription] = useState(null) const [subscriptionLoading, setSubscriptionLoading] = useState(false) + const [showFinishSetupBanner, setShowFinishSetupBanner] = useState(true) useEffect(() => { if (user?.org_id) { @@ -110,6 +111,22 @@ export default function HomePage() { } }, [user]) + useEffect(() => { + if (typeof window === 'undefined') return + if (localStorage.getItem('pulse_welcome_completed') === 'true') setShowFinishSetupBanner(false) + }, [user?.org_id]) + + useEffect(() => { + if (typeof window === 'undefined') return + const params = new URLSearchParams(window.location.search) + if (params.get('trial_started') === '1') { + toast.success('Your trial is active. You can add sites and start tracking.') + params.delete('trial_started') + const newUrl = params.toString() ? `${window.location.pathname}?${params}` : window.location.pathname + window.history.replaceState({}, '', newUrl) + } + }, []) + const loadSites = async () => { try { setSitesLoading(true) @@ -289,6 +306,32 @@ export default function HomePage() { return (
+ {showFinishSetupBanner && ( +
+

+ Finish setting up your workspace and add your first site. +

+
+ + + + +
+
+ )} +

Your Sites

@@ -371,6 +414,23 @@ export default function HomePage() {
+ {!sitesLoading && sites.length === 0 && ( +
+
+ +
+

Add your first site

+

+ Connect a domain to start collecting privacy-friendly analytics. You can add more sites later from the dashboard. +

+ + + +
+ )} +
) diff --git a/app/welcome/page.tsx b/app/welcome/page.tsx index 42e8363..dc93780 100644 --- a/app/welcome/page.tsx +++ b/app/welcome/page.tsx @@ -20,23 +20,56 @@ 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 { + trackWelcomeStepView, + 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, GlobeIcon, ZapIcon, } 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() @@ -117,9 +150,12 @@ function WelcomeContent() { login(result.user) router.refresh() } + trackWelcomeWorkspaceCreated(!!(typeof window !== 'undefined' && localStorage.getItem('pulse_pending_checkout'))) setStep(3) } catch (err: unknown) { - setOrgError(getAuthErrorMessage(err) || (err as Error)?.message || 'Failed to create workspace') + const { message, suggestSlug } = getOrgErrorMessage(err, orgSlug, 'Failed to create workspace') + setOrgError(message) + if (suggestSlug) setOrgSlug(suggestSlug) } finally { setOrgLoading(false) } @@ -134,6 +170,7 @@ function WelcomeContent() { setPlanLoading(true) setPlanError('') try { + trackWelcomePlanContinue() const intent = JSON.parse(raw) const { url } = await createCheckoutSession({ plan_id: intent.planId, @@ -156,6 +193,7 @@ function WelcomeContent() { } const handlePlanSkip = () => { + trackWelcomePlanSkip() localStorage.removeItem('pulse_pending_checkout') setDismissedPendingCheckout(true) setStep(4) @@ -172,6 +210,8 @@ function WelcomeContent() { 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) { @@ -181,9 +221,17 @@ function WelcomeContent() { } } - const handleSkipSite = () => setStep(5) + const handleSkipSite = () => { + trackWelcomeSiteSkipped() + if (typeof window !== 'undefined') sessionStorage.removeItem(SITE_DRAFT_KEY) + setStep(5) + } - const goToDashboard = () => router.push('/') + 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 = @@ -195,6 +243,31 @@ function WelcomeContent() { } }, [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 } @@ -214,7 +287,14 @@ function WelcomeContent() { return (
-
+
{Array.from({ length: TOTAL_STEPS }, (_, i) => (
))}
@@ -270,6 +351,15 @@ function WelcomeContent() { transition={{ duration: 0.25 }} className={cardClass} > +
@@ -332,6 +422,15 @@ function WelcomeContent() { transition={{ duration: 0.25 }} className={cardClass} > +
@@ -369,14 +468,21 @@ function WelcomeContent() { ) : ( - + <> + +

+ + View pricing + +

+ )}
{showPendingCheckoutInStep3 && ( @@ -402,6 +508,15 @@ function WelcomeContent() { transition={{ duration: 0.25 }} className={cardClass} > +
diff --git a/components/sites/SiteList.tsx b/components/sites/SiteList.tsx index 37bf960..b88004f 100644 --- a/components/sites/SiteList.tsx +++ b/components/sites/SiteList.tsx @@ -28,7 +28,12 @@ export default function SiteList({ sites, loading, onDelete }: SiteListProps) { return (

No sites yet

-

Create your first site to get started.

+

Create your first site to get started.

+ + +
) } diff --git a/lib/welcomeAnalytics.ts b/lib/welcomeAnalytics.ts new file mode 100644 index 0000000..d0887bf --- /dev/null +++ b/lib/welcomeAnalytics.ts @@ -0,0 +1,72 @@ +/** + * Welcome/onboarding analytics. Emits custom events for step views and actions + * so drop-off and funnel can be measured. Listen for 'pulse_welcome' or use + * the payload with your analytics backend. + */ + +export type WelcomeEventName = + | 'welcome_step_view' + | 'welcome_workspace_created' + | 'welcome_plan_continue' + | 'welcome_plan_skip' + | 'welcome_site_added' + | 'welcome_site_skipped' + | 'welcome_completed' + +export interface WelcomeEventPayload { + event: WelcomeEventName + step?: number + /** For workspace_created: has pending checkout */ + had_pending_checkout?: boolean + /** For site_added: whether user added a site in wizard */ + added_site?: boolean +} + +const STORAGE_KEY = 'pulse_welcome_events' + +function emit(event: WelcomeEventName, payload: Omit = {}) { + const full: WelcomeEventPayload = { event, ...payload } + if (typeof window === 'undefined') return + try { + window.dispatchEvent( + new CustomEvent('pulse_welcome', { detail: full }) + ) + if (process.env.NODE_ENV === 'development') { + // eslint-disable-next-line no-console + console.debug('[Pulse Welcome]', full) + } + const queue = JSON.parse(sessionStorage.getItem(STORAGE_KEY) || '[]') + queue.push({ ...full, ts: Date.now() }) + sessionStorage.setItem(STORAGE_KEY, JSON.stringify(queue.slice(-50))) + } catch { + // ignore + } +} + +export function trackWelcomeStepView(step: number) { + emit('welcome_step_view', { step }) +} + +export function trackWelcomeWorkspaceCreated(hadPendingCheckout: boolean) { + emit('welcome_workspace_created', { had_pending_checkout: hadPendingCheckout }) +} + +export function trackWelcomePlanContinue() { + emit('welcome_plan_continue') +} + +export function trackWelcomePlanSkip() { + emit('welcome_plan_skip') +} + +export function trackWelcomeSiteAdded() { + emit('welcome_site_added', { added_site: true }) +} + +export function trackWelcomeSiteSkipped() { + emit('welcome_site_skipped') +} + +export function trackWelcomeCompleted(addedSite: boolean) { + emit('welcome_completed', { added_site: addedSite }) +} From a211193277ecd8e37653db6c73a026685ef4ebe5 Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Sun, 8 Feb 2026 14:21:38 +0100 Subject: [PATCH 3/9] feat: enhance welcome page with organization selection and workspace switching functionality --- app/welcome/page.tsx | 134 ++++++++++++++++++++++++++++++++-------- lib/welcomeAnalytics.ts | 5 ++ 2 files changed, 113 insertions(+), 26 deletions(-) diff --git a/app/welcome/page.tsx b/app/welcome/page.tsx index dc93780..f182db3 100644 --- a/app/welcome/page.tsx +++ b/app/welcome/page.tsx @@ -14,6 +14,7 @@ import { getUserOrganizations, switchContext, type Organization, + type OrganizationMember, } from '@/lib/api/organization' import { createCheckoutSession } from '@/lib/api/billing' import { createSite, type Site } from '@/lib/api/sites' @@ -22,6 +23,7 @@ import { useAuth } from '@/lib/auth/context' import { getAuthErrorMessage } from '@/lib/utils/authErrors' import { trackWelcomeStepView, + trackWelcomeWorkspaceSelected, trackWelcomeWorkspaceCreated, trackWelcomePlanContinue, trackWelcomePlanSkip, @@ -38,6 +40,7 @@ import { BarChartIcon, GlobeIcon, ZapIcon, + PlusIcon, } from '@ciphera-net/ui' import Link from 'next/link' @@ -97,6 +100,10 @@ function WelcomeContent() { 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) @@ -113,22 +120,45 @@ function WelcomeContent() { if (stepFromUrl !== step) setStepState(stepFromUrl) }, [stepParam, step]) - // * If user already has orgs and no pending checkout, send to dashboard (avoid re-doing wizard) + // * 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 || orgs.length === 0) return - if (!localStorage.getItem('pulse_pending_checkout')) { - router.replace('/') - } + if (!cancelled) setOrganizations(orgs || []) + }) + .catch(() => { + if (!cancelled) setOrganizations([]) + }) + .finally(() => { + if (!cancelled) setOrgsLoading(false) }) - .catch(() => {}) return () => { cancelled = true } - }, [user, step, router]) + }, [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 @@ -272,6 +302,10 @@ function WelcomeContent() { return } + if (switchingOrgId) { + return + } + if (redirectingCheckout || (planLoading && step === 3)) { return ( -
-
- + {orgsLoading ? ( +
+

Loading your workspaces...

-

- Welcome to Pulse -

-

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

- -
+ ) : 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. +

+ +
+ )} )} diff --git a/lib/welcomeAnalytics.ts b/lib/welcomeAnalytics.ts index d0887bf..9f1ee71 100644 --- a/lib/welcomeAnalytics.ts +++ b/lib/welcomeAnalytics.ts @@ -6,6 +6,7 @@ export type WelcomeEventName = | 'welcome_step_view' + | 'welcome_workspace_selected' | 'welcome_workspace_created' | 'welcome_plan_continue' | 'welcome_plan_skip' @@ -47,6 +48,10 @@ export function trackWelcomeStepView(step: number) { emit('welcome_step_view', { step }) } +export function trackWelcomeWorkspaceSelected() { + emit('welcome_workspace_selected') +} + export function trackWelcomeWorkspaceCreated(hadPendingCheckout: boolean) { emit('welcome_workspace_created', { had_pending_checkout: hadPendingCheckout }) } From 30dcd5ddbc1a48b883f0f2ce9cf259186af65112 Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Sun, 8 Feb 2026 14:29:33 +0100 Subject: [PATCH 4/9] fix: update auth callback routing to redirect to '/welcome' for pending checkouts --- app/auth/callback/page.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/app/auth/callback/page.tsx b/app/auth/callback/page.tsx index 0537e22..631d19e 100644 --- a/app/auth/callback/page.tsx +++ b/app/auth/callback/page.tsx @@ -52,8 +52,12 @@ function AuthCallbackContent() { const result = await setSessionAction(token, refreshToken) if (result.success && result.user) { login(result.user) - const returnTo = searchParams.get('returnTo') || '/' - router.push(returnTo) + if (typeof window !== 'undefined' && localStorage.getItem('pulse_pending_checkout')) { + router.push('/welcome') + } else { + const returnTo = searchParams.get('returnTo') || '/' + router.push(returnTo) + } } else { setError(authMessageFromErrorType('invalid')) } From b5f95a8abcd6f14df675cb4bc131e9036d185169 Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Sun, 8 Feb 2026 14:42:52 +0100 Subject: [PATCH 5/9] feat: add integration setup options and script copying functionality to welcome page --- app/welcome/page.tsx | 102 +++++++++++++++++++++++++++++++++++++++---- 1 file changed, 94 insertions(+), 8 deletions(-) diff --git a/app/welcome/page.tsx b/app/welcome/page.tsx index f182db3..691780a 100644 --- a/app/welcome/page.tsx +++ b/app/welcome/page.tsx @@ -21,6 +21,8 @@ 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, @@ -35,6 +37,7 @@ import { LoadingOverlay, Button, Input } from '@ciphera-net/ui' import { toast } from '@ciphera-net/ui' import { CheckCircleIcon, + CheckIcon, ArrowRightIcon, ArrowLeftIcon, BarChartIcon, @@ -104,6 +107,9 @@ function WelcomeContent() { 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) @@ -264,6 +270,17 @@ function WelcomeContent() { } 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 @@ -688,16 +705,85 @@ function WelcomeContent() { ? `"${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 && ( +
+

+ 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 && ( + + )}
)} From a7933443ef026583445110958d5fd74f3b46a247 Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Sun, 8 Feb 2026 14:50:03 +0100 Subject: [PATCH 6/9] fix: update link in welcome page to open integrations in a new tab with appropriate security attributes --- app/welcome/page.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/welcome/page.tsx b/app/welcome/page.tsx index 691780a..472d16d 100644 --- a/app/welcome/page.tsx +++ b/app/welcome/page.tsx @@ -735,7 +735,7 @@ function WelcomeContent() { ))}

- + View all integrations →

From 1404ac5d7738d92d69cfb49a08e5ab599e710bfa Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Sun, 8 Feb 2026 15:06:46 +0100 Subject: [PATCH 7/9] fix: conditionally render SiteList component based on loading state and site availability --- app/page.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/page.tsx b/app/page.tsx index 35119cb..c8b58f0 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -431,7 +431,9 @@ export default function HomePage() {
)} - + {(sitesLoading || sites.length > 0) && ( + + )}
) } From 39e90f4f09d4c7b6984a40a64870f4691d67a2c0 Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Sun, 8 Feb 2026 15:09:17 +0100 Subject: [PATCH 8/9] feat: implement site creation confirmation with integration options and script copying functionality --- app/sites/new/page.tsx | 124 +++++++++++++++++++++++++++++++++++++++-- 1 file changed, 120 insertions(+), 4 deletions(-) diff --git a/app/sites/new/page.tsx b/app/sites/new/page.tsx index 0350d07..b4f0522 100644 --- a/app/sites/new/page.tsx +++ b/app/sites/new/page.tsx @@ -1,12 +1,18 @@ 'use client' -import { useState, useEffect } from 'react' +import { useState, useEffect, useCallback } from 'react' import { useRouter } from 'next/navigation' -import { createSite, listSites } from '@/lib/api/sites' +import Link from 'next/link' +import { createSite, listSites, type Site } from '@/lib/api/sites' import { getSubscription } from '@/lib/api/billing' +import { API_URL, APP_URL } from '@/lib/api/client' +import { integrations, getIntegration } from '@/lib/integrations' import { toast } from '@ciphera-net/ui' import { getAuthErrorMessage } from '@/lib/utils/authErrors' import { Button, Input } from '@ciphera-net/ui' +import { CheckCircleIcon, CheckIcon } from '@ciphera-net/ui' + +const popularIntegrations = integrations.filter((i) => i.category === 'framework').slice(0, 10) export default function NewSitePage() { const router = useRouter() @@ -15,6 +21,9 @@ export default function NewSitePage() { name: '', domain: '', }) + const [createdSite, setCreatedSite] = useState(null) + const [selectedIntegrationSlug, setSelectedIntegrationSlug] = useState(null) + const [scriptCopied, setScriptCopied] = useState(false) // * Check for plan limits on mount useEffect(() => { @@ -38,6 +47,15 @@ export default function NewSitePage() { checkLimits() }, [router]) + 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 handleSubmit = async (e: React.FormEvent) => { e.preventDefault() setLoading(true) @@ -45,14 +63,112 @@ export default function NewSitePage() { try { const site = await createSite(formData) toast.success('Site created successfully') - router.push(`/sites/${site.id}`) - } catch (error: any) { + setCreatedSite(site) + } catch (error: unknown) { toast.error(getAuthErrorMessage(error) || 'Failed to create site: ' + ((error as Error)?.message || 'Unknown error')) } finally { setLoading(false) } } + // * Step 2: Show framework picker + script (same as /welcome after adding first site) + if (createdSite) { + return ( +
+
+
+
+ +
+

+ Site created +

+

+ Add the script to your site to start collecting data. +

+
+ +
+

+ 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 → + +

+ )} +
+ +
+ + +
+
+
+ ) + } + + // * Step 1: Name & domain form return (

From bd2aca7a76d3f069f4074cf2f3e6fe965aa8023c Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Sun, 8 Feb 2026 15:18:33 +0100 Subject: [PATCH 9/9] feat: integrate ScriptSetupBlock component for improved site setup instructions and tracking script functionality across pages --- app/sites/[id]/settings/page.tsx | 34 ++--- app/sites/new/layout.tsx | 14 +++ app/sites/new/page.tsx | 174 +++++++++++++------------- app/welcome/layout.tsx | 14 +++ app/welcome/page.tsx | 85 +------------ components/sites/ScriptSetupBlock.tsx | 124 ++++++++++++++++++ lib/welcomeAnalytics.ts | 10 ++ 7 files changed, 266 insertions(+), 189 deletions(-) create mode 100644 app/sites/new/layout.tsx create mode 100644 app/welcome/layout.tsx create mode 100644 components/sites/ScriptSetupBlock.tsx diff --git a/app/sites/[id]/settings/page.tsx b/app/sites/[id]/settings/page.tsx index 8ea491f..32935e2 100644 --- a/app/sites/[id]/settings/page.tsx +++ b/app/sites/[id]/settings/page.tsx @@ -8,9 +8,10 @@ import { toast } from '@ciphera-net/ui' import { getAuthErrorMessage } from '@/lib/utils/authErrors' import { LoadingOverlay } from '@ciphera-net/ui' import VerificationModal from '@/components/sites/VerificationModal' +import ScriptSetupBlock from '@/components/sites/ScriptSetupBlock' import { PasswordInput } from '@ciphera-net/ui' import { Select, Modal, Button } from '@ciphera-net/ui' -import { APP_URL, API_URL } from '@/lib/api/client' +import { APP_URL } from '@/lib/api/client' import { generatePrivacySnippet } from '@/lib/utils/privacySnippet' import { motion, AnimatePresence } from 'framer-motion' import { useAuth } from '@/lib/auth/context' @@ -69,7 +70,6 @@ export default function SiteSettingsPage() { // Bot and noise filtering filter_bots: true }) - const [scriptCopied, setScriptCopied] = useState(false) const [linkCopied, setLinkCopied] = useState(false) const [snippetCopied, setSnippetCopied] = useState(false) const [showVerificationModal, setShowVerificationModal] = useState(false) @@ -266,14 +266,6 @@ export default function SiteSettingsPage() { } } - const copyScript = () => { - const script = `` - navigator.clipboard.writeText(script) - setScriptCopied(true) - toast.success('Script copied to clipboard') - setTimeout(() => setScriptCopied(false), 2000) - } - const copyLink = () => { const link = `${APP_URL}/share/${siteId}` navigator.clipboard.writeText(link) @@ -443,23 +435,15 @@ export default function SiteSettingsPage() {

Tracking Script

- Add this script to your website to start tracking visitors. + Add this script to your website to start tracking visitors. Choose your framework for setup instructions.

-
- - {``} - - -
+ -
+
- ))} -
-

- - View all integrations → - -

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

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

- )} +
+ +

+ Check if your site is sending data correctly. +

+
+ +
+
-
+ + setShowVerificationModal(false)} + site={createdSite} + />

) } @@ -175,6 +175,12 @@ export default function NewSitePage() { Create New Site + {atLimit && limitsChecked && ( +

+ Plan limit reached. Upgrade to add more sites. +

+ )} +
)} diff --git a/components/sites/ScriptSetupBlock.tsx b/components/sites/ScriptSetupBlock.tsx new file mode 100644 index 0000000..4d16d76 --- /dev/null +++ b/components/sites/ScriptSetupBlock.tsx @@ -0,0 +1,124 @@ +'use client' + +/** + * Shared block: framework picker, tracking script snippet with copy, and integration guide links. + * Used on welcome (step 5), /sites/new (step 2), and site settings. + */ + +import { useState, useCallback } from 'react' +import Link from 'next/link' +import { API_URL, APP_URL } from '@/lib/api/client' +import { integrations, getIntegration } from '@/lib/integrations' +import { toast } from '@ciphera-net/ui' +import { CheckIcon } from '@ciphera-net/ui' + +const POPULAR_INTEGRATIONS = integrations.filter((i) => i.category === 'framework').slice(0, 10) + +export interface ScriptSetupBlockSite { + domain: string + name?: string +} + +interface ScriptSetupBlockProps { + /** Site domain (and optional name for display). */ + site: ScriptSetupBlockSite + /** Called when user copies the script (e.g. for analytics). */ + onScriptCopy?: () => void + /** Show framework picker and "View all integrations" / "See full guide" links. Default true. */ + showFrameworkPicker?: boolean + /** Optional class for the root wrapper. */ + className?: string +} + +export default function ScriptSetupBlock({ + site, + onScriptCopy, + showFrameworkPicker = true, + className = '', +}: ScriptSetupBlockProps) { + const [selectedIntegrationSlug, setSelectedIntegrationSlug] = useState(null) + const [scriptCopied, setScriptCopied] = useState(false) + + const copyScript = useCallback(() => { + const script = `` + navigator.clipboard.writeText(script) + setScriptCopied(true) + toast.success('Script copied to clipboard') + onScriptCopy?.() + setTimeout(() => setScriptCopied(false), 2000) + }, [site.domain, onScriptCopy]) + + return ( +
+ {showFrameworkPicker && ( + <> +

+ Add the script to your site +

+

+ Choose your framework for setup instructions. +

+
+ {POPULAR_INTEGRATIONS.map((int) => ( + + ))} +
+

+ + View all integrations → + +

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

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

+ )} +
+ ) +} diff --git a/lib/welcomeAnalytics.ts b/lib/welcomeAnalytics.ts index 9f1ee71..5c2f2db 100644 --- a/lib/welcomeAnalytics.ts +++ b/lib/welcomeAnalytics.ts @@ -13,6 +13,8 @@ export type WelcomeEventName = | 'welcome_site_added' | 'welcome_site_skipped' | 'welcome_completed' + | 'site_created_from_dashboard' + | 'site_created_script_copied' export interface WelcomeEventPayload { event: WelcomeEventName @@ -75,3 +77,11 @@ export function trackWelcomeSiteSkipped() { export function trackWelcomeCompleted(addedSite: boolean) { emit('welcome_completed', { added_site: addedSite }) } + +export function trackSiteCreatedFromDashboard() { + emit('site_created_from_dashboard') +} + +export function trackSiteCreatedScriptCopied() { + emit('site_created_script_copied') +}