From 58cfb6210b985788f071025846fee22225c19cf0 Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Sun, 8 Feb 2026 14:09:54 +0100 Subject: [PATCH] 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 }) +}