feat: add setup banner and site addition prompt to homepage; enhance welcome page with tracking and error handling
This commit is contained in:
62
app/page.tsx
62
app/page.tsx
@@ -10,7 +10,7 @@ import { getSubscription, type SubscriptionDetails } from '@/lib/api/billing'
|
|||||||
import { LoadingOverlay } from '@ciphera-net/ui'
|
import { LoadingOverlay } from '@ciphera-net/ui'
|
||||||
import SiteList from '@/components/sites/SiteList'
|
import SiteList from '@/components/sites/SiteList'
|
||||||
import { Button } from '@ciphera-net/ui'
|
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 { toast } from '@ciphera-net/ui'
|
||||||
import { getAuthErrorMessage } from '@/lib/utils/authErrors'
|
import { getAuthErrorMessage } from '@/lib/utils/authErrors'
|
||||||
|
|
||||||
@@ -102,6 +102,7 @@ export default function HomePage() {
|
|||||||
const [sitesLoading, setSitesLoading] = useState(true)
|
const [sitesLoading, setSitesLoading] = useState(true)
|
||||||
const [subscription, setSubscription] = useState<SubscriptionDetails | null>(null)
|
const [subscription, setSubscription] = useState<SubscriptionDetails | null>(null)
|
||||||
const [subscriptionLoading, setSubscriptionLoading] = useState(false)
|
const [subscriptionLoading, setSubscriptionLoading] = useState(false)
|
||||||
|
const [showFinishSetupBanner, setShowFinishSetupBanner] = useState(true)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (user?.org_id) {
|
if (user?.org_id) {
|
||||||
@@ -110,6 +111,22 @@ export default function HomePage() {
|
|||||||
}
|
}
|
||||||
}, [user])
|
}, [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 () => {
|
const loadSites = async () => {
|
||||||
try {
|
try {
|
||||||
setSitesLoading(true)
|
setSitesLoading(true)
|
||||||
@@ -289,6 +306,32 @@ export default function HomePage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 py-8">
|
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 py-8">
|
||||||
|
{showFinishSetupBanner && (
|
||||||
|
<div className="mb-6 flex items-center justify-between gap-4 rounded-2xl border border-brand-orange/30 bg-brand-orange/5 px-4 py-3 dark:bg-brand-orange/10">
|
||||||
|
<p className="text-sm text-neutral-700 dark:text-neutral-300">
|
||||||
|
Finish setting up your workspace and add your first site.
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-2 flex-shrink-0">
|
||||||
|
<Link href="/welcome?step=5">
|
||||||
|
<Button variant="primary" className="text-sm">
|
||||||
|
Finish setup
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
if (typeof window !== 'undefined') localStorage.setItem('pulse_welcome_completed', 'true')
|
||||||
|
setShowFinishSetupBanner(false)
|
||||||
|
}}
|
||||||
|
className="text-neutral-500 hover:text-neutral-700 dark:hover:text-neutral-400 p-1 rounded"
|
||||||
|
aria-label="Dismiss"
|
||||||
|
>
|
||||||
|
<XIcon className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="mb-8 flex items-center justify-between">
|
<div className="mb-8 flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-neutral-900 dark:text-white">Your Sites</h1>
|
<h1 className="text-2xl font-bold text-neutral-900 dark:text-white">Your Sites</h1>
|
||||||
@@ -371,6 +414,23 @@ export default function HomePage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{!sitesLoading && sites.length === 0 && (
|
||||||
|
<div className="mb-8 rounded-2xl border-2 border-dashed border-brand-orange/30 bg-brand-orange/5 p-8 text-center dark:bg-brand-orange/10">
|
||||||
|
<div className="mx-auto flex h-14 w-14 items-center justify-center rounded-full bg-brand-orange/20 text-brand-orange mb-4">
|
||||||
|
<GlobeIcon className="h-7 w-7" />
|
||||||
|
</div>
|
||||||
|
<h2 className="text-xl font-bold text-neutral-900 dark:text-white mb-2">Add your first site</h2>
|
||||||
|
<p className="text-neutral-600 dark:text-neutral-400 mb-6 max-w-md mx-auto">
|
||||||
|
Connect a domain to start collecting privacy-friendly analytics. You can add more sites later from the dashboard.
|
||||||
|
</p>
|
||||||
|
<Link href="/sites/new">
|
||||||
|
<Button variant="primary" className="min-w-[180px]">
|
||||||
|
Add your first site
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<SiteList sites={sites} loading={sitesLoading} onDelete={handleDelete} />
|
<SiteList sites={sites} loading={sitesLoading} onDelete={handleDelete} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -20,23 +20,56 @@ import { createSite, type Site } from '@/lib/api/sites'
|
|||||||
import { setSessionAction } from '@/app/actions/auth'
|
import { setSessionAction } from '@/app/actions/auth'
|
||||||
import { useAuth } from '@/lib/auth/context'
|
import { useAuth } from '@/lib/auth/context'
|
||||||
import { getAuthErrorMessage } from '@/lib/utils/authErrors'
|
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 { LoadingOverlay, Button, Input } from '@ciphera-net/ui'
|
||||||
import { toast } from '@ciphera-net/ui'
|
import { toast } from '@ciphera-net/ui'
|
||||||
import {
|
import {
|
||||||
CheckCircleIcon,
|
CheckCircleIcon,
|
||||||
ArrowRightIcon,
|
ArrowRightIcon,
|
||||||
|
ArrowLeftIcon,
|
||||||
BarChartIcon,
|
BarChartIcon,
|
||||||
GlobeIcon,
|
GlobeIcon,
|
||||||
ZapIcon,
|
ZapIcon,
|
||||||
} from '@ciphera-net/ui'
|
} from '@ciphera-net/ui'
|
||||||
|
import Link from 'next/link'
|
||||||
|
|
||||||
const TOTAL_STEPS = 5
|
const TOTAL_STEPS = 5
|
||||||
const DEFAULT_ORG_NAME = 'My workspace'
|
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 {
|
function slugFromName(name: string): string {
|
||||||
return name.toLowerCase().replace(/[^a-z0-9]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '') || 'my-workspace'
|
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() {
|
function WelcomeContent() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const searchParams = useSearchParams()
|
const searchParams = useSearchParams()
|
||||||
@@ -117,9 +150,12 @@ function WelcomeContent() {
|
|||||||
login(result.user)
|
login(result.user)
|
||||||
router.refresh()
|
router.refresh()
|
||||||
}
|
}
|
||||||
|
trackWelcomeWorkspaceCreated(!!(typeof window !== 'undefined' && localStorage.getItem('pulse_pending_checkout')))
|
||||||
setStep(3)
|
setStep(3)
|
||||||
} catch (err: unknown) {
|
} 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 {
|
} finally {
|
||||||
setOrgLoading(false)
|
setOrgLoading(false)
|
||||||
}
|
}
|
||||||
@@ -134,6 +170,7 @@ function WelcomeContent() {
|
|||||||
setPlanLoading(true)
|
setPlanLoading(true)
|
||||||
setPlanError('')
|
setPlanError('')
|
||||||
try {
|
try {
|
||||||
|
trackWelcomePlanContinue()
|
||||||
const intent = JSON.parse(raw)
|
const intent = JSON.parse(raw)
|
||||||
const { url } = await createCheckoutSession({
|
const { url } = await createCheckoutSession({
|
||||||
plan_id: intent.planId,
|
plan_id: intent.planId,
|
||||||
@@ -156,6 +193,7 @@ function WelcomeContent() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handlePlanSkip = () => {
|
const handlePlanSkip = () => {
|
||||||
|
trackWelcomePlanSkip()
|
||||||
localStorage.removeItem('pulse_pending_checkout')
|
localStorage.removeItem('pulse_pending_checkout')
|
||||||
setDismissedPendingCheckout(true)
|
setDismissedPendingCheckout(true)
|
||||||
setStep(4)
|
setStep(4)
|
||||||
@@ -172,6 +210,8 @@ function WelcomeContent() {
|
|||||||
domain: siteDomain.trim().toLowerCase(),
|
domain: siteDomain.trim().toLowerCase(),
|
||||||
})
|
})
|
||||||
setCreatedSite(site)
|
setCreatedSite(site)
|
||||||
|
if (typeof window !== 'undefined') sessionStorage.removeItem(SITE_DRAFT_KEY)
|
||||||
|
trackWelcomeSiteAdded()
|
||||||
toast.success('Site added')
|
toast.success('Site added')
|
||||||
setStep(5)
|
setStep(5)
|
||||||
} catch (err: unknown) {
|
} 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 goToSite = () => createdSite && router.push(`/sites/${createdSite.id}`)
|
||||||
|
|
||||||
const showPendingCheckoutInStep3 =
|
const showPendingCheckoutInStep3 =
|
||||||
@@ -195,6 +243,31 @@ function WelcomeContent() {
|
|||||||
}
|
}
|
||||||
}, [step, hadPendingCheckout])
|
}, [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) {
|
if (orgLoading && step === 2) {
|
||||||
return <LoadingOverlay logoSrc="/pulse_icon_no_margins.png" title="Creating your workspace..." />
|
return <LoadingOverlay logoSrc="/pulse_icon_no_margins.png" title="Creating your workspace..." />
|
||||||
}
|
}
|
||||||
@@ -214,7 +287,14 @@ function WelcomeContent() {
|
|||||||
return (
|
return (
|
||||||
<div className="min-h-[80vh] flex flex-col items-center justify-center bg-neutral-50 dark:bg-neutral-950 px-4 py-12">
|
<div className="min-h-[80vh] flex flex-col items-center justify-center bg-neutral-50 dark:bg-neutral-950 px-4 py-12">
|
||||||
<div className="w-full max-w-lg">
|
<div className="w-full max-w-lg">
|
||||||
<div className="flex justify-center gap-1.5 mb-8">
|
<div
|
||||||
|
className="flex justify-center gap-1.5 mb-8"
|
||||||
|
role="progressbar"
|
||||||
|
aria-valuenow={step}
|
||||||
|
aria-valuemin={1}
|
||||||
|
aria-valuemax={TOTAL_STEPS}
|
||||||
|
aria-label={`Step ${step} of ${TOTAL_STEPS}`}
|
||||||
|
>
|
||||||
{Array.from({ length: TOTAL_STEPS }, (_, i) => (
|
{Array.from({ length: TOTAL_STEPS }, (_, i) => (
|
||||||
<div
|
<div
|
||||||
key={i}
|
key={i}
|
||||||
@@ -223,7 +303,8 @@ function WelcomeContent() {
|
|||||||
? 'bg-brand-orange w-8'
|
? 'bg-brand-orange w-8'
|
||||||
: 'bg-neutral-200 dark:bg-neutral-700 w-6'
|
: 'bg-neutral-200 dark:bg-neutral-700 w-6'
|
||||||
}`}
|
}`}
|
||||||
aria-hidden
|
aria-current={i + 1 === step ? 'step' : undefined}
|
||||||
|
aria-label={`Step ${i + 1} of ${TOTAL_STEPS}`}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -270,6 +351,15 @@ function WelcomeContent() {
|
|||||||
transition={{ duration: 0.25 }}
|
transition={{ duration: 0.25 }}
|
||||||
className={cardClass}
|
className={cardClass}
|
||||||
>
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setStep(1)}
|
||||||
|
className="flex items-center gap-1.5 text-sm text-neutral-500 dark:text-neutral-400 hover:text-neutral-700 dark:hover:text-neutral-300 mb-6"
|
||||||
|
aria-label="Back to welcome"
|
||||||
|
>
|
||||||
|
<ArrowLeftIcon className="h-4 w-4" />
|
||||||
|
Back
|
||||||
|
</button>
|
||||||
<div className="text-center mb-6">
|
<div className="text-center mb-6">
|
||||||
<div className="mx-auto flex h-14 w-14 items-center justify-center rounded-full bg-brand-orange/10 text-brand-orange mb-4">
|
<div className="mx-auto flex h-14 w-14 items-center justify-center rounded-full bg-brand-orange/10 text-brand-orange mb-4">
|
||||||
<BarChartIcon className="h-7 w-7" />
|
<BarChartIcon className="h-7 w-7" />
|
||||||
@@ -332,6 +422,15 @@ function WelcomeContent() {
|
|||||||
transition={{ duration: 0.25 }}
|
transition={{ duration: 0.25 }}
|
||||||
className={cardClass}
|
className={cardClass}
|
||||||
>
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setStep(2)}
|
||||||
|
className="flex items-center gap-1.5 text-sm text-neutral-500 dark:text-neutral-400 hover:text-neutral-700 dark:hover:text-neutral-300 mb-6"
|
||||||
|
aria-label="Back to workspace"
|
||||||
|
>
|
||||||
|
<ArrowLeftIcon className="h-4 w-4" />
|
||||||
|
Back
|
||||||
|
</button>
|
||||||
<div className="text-center mb-6">
|
<div className="text-center mb-6">
|
||||||
<div className="mx-auto flex h-14 w-14 items-center justify-center rounded-full bg-green-500/10 text-green-600 dark:text-green-400 mb-4">
|
<div className="mx-auto flex h-14 w-14 items-center justify-center rounded-full bg-green-500/10 text-green-600 dark:text-green-400 mb-4">
|
||||||
<CheckCircleIcon className="h-7 w-7" />
|
<CheckCircleIcon className="h-7 w-7" />
|
||||||
@@ -369,14 +468,21 @@ function WelcomeContent() {
|
|||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<Button
|
<>
|
||||||
variant="primary"
|
<Button
|
||||||
className="w-full sm:w-auto"
|
variant="primary"
|
||||||
onClick={() => setStep(4)}
|
className="w-full sm:w-auto"
|
||||||
>
|
onClick={() => setStep(4)}
|
||||||
Continue
|
>
|
||||||
<ArrowRightIcon className="ml-2 h-4 w-4" />
|
Continue
|
||||||
</Button>
|
<ArrowRightIcon className="ml-2 h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<p className="mt-4 text-center">
|
||||||
|
<Link href="/pricing" className="text-sm text-brand-orange hover:underline">
|
||||||
|
View pricing
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{showPendingCheckoutInStep3 && (
|
{showPendingCheckoutInStep3 && (
|
||||||
@@ -402,6 +508,15 @@ function WelcomeContent() {
|
|||||||
transition={{ duration: 0.25 }}
|
transition={{ duration: 0.25 }}
|
||||||
className={cardClass}
|
className={cardClass}
|
||||||
>
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setStep(3)}
|
||||||
|
className="flex items-center gap-1.5 text-sm text-neutral-500 dark:text-neutral-400 hover:text-neutral-700 dark:hover:text-neutral-300 mb-6"
|
||||||
|
aria-label="Back to plan"
|
||||||
|
>
|
||||||
|
<ArrowLeftIcon className="h-4 w-4" />
|
||||||
|
Back
|
||||||
|
</button>
|
||||||
<div className="text-center mb-6">
|
<div className="text-center mb-6">
|
||||||
<div className="mx-auto flex h-14 w-14 items-center justify-center rounded-full bg-brand-orange/10 text-brand-orange mb-4">
|
<div className="mx-auto flex h-14 w-14 items-center justify-center rounded-full bg-brand-orange/10 text-brand-orange mb-4">
|
||||||
<GlobeIcon className="h-7 w-7" />
|
<GlobeIcon className="h-7 w-7" />
|
||||||
|
|||||||
@@ -28,7 +28,12 @@ export default function SiteList({ sites, loading, onDelete }: SiteListProps) {
|
|||||||
return (
|
return (
|
||||||
<div className="rounded-lg border border-dashed border-neutral-300 dark:border-neutral-700 p-12 text-center">
|
<div className="rounded-lg border border-dashed border-neutral-300 dark:border-neutral-700 p-12 text-center">
|
||||||
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white">No sites yet</h3>
|
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white">No sites yet</h3>
|
||||||
<p className="mt-2 text-sm text-neutral-500 dark:text-neutral-400">Create your first site to get started.</p>
|
<p className="mt-2 text-sm text-neutral-500 dark:text-neutral-400 mb-4">Create your first site to get started.</p>
|
||||||
|
<Link href="/sites/new">
|
||||||
|
<Button variant="primary" className="text-sm">
|
||||||
|
Add your first site
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
72
lib/welcomeAnalytics.ts
Normal file
72
lib/welcomeAnalytics.ts
Normal file
@@ -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<WelcomeEventPayload, 'event'> = {}) {
|
||||||
|
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 })
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user