[PULSE-49] Welcome flow, add-site step 2, shared ScriptSetupBlock, and dashboard empty state #17
@@ -27,7 +27,7 @@ function AuthCallbackContent() {
|
|||||||
localStorage.removeItem('oauth_state')
|
localStorage.removeItem('oauth_state')
|
||||||
localStorage.removeItem('oauth_code_verifier')
|
localStorage.removeItem('oauth_code_verifier')
|
||||||
if (localStorage.getItem('pulse_pending_checkout')) {
|
if (localStorage.getItem('pulse_pending_checkout')) {
|
||||||
router.push('/pricing')
|
router.push('/welcome')
|
||||||
} else {
|
} else {
|
||||||
router.push('/')
|
router.push('/')
|
||||||
}
|
}
|
||||||
@@ -52,8 +52,12 @@ function AuthCallbackContent() {
|
|||||||
const result = await setSessionAction(token, refreshToken)
|
const result = await setSessionAction(token, refreshToken)
|
||||||
if (result.success && result.user) {
|
if (result.success && result.user) {
|
||||||
login(result.user)
|
login(result.user)
|
||||||
const returnTo = searchParams.get('returnTo') || '/'
|
if (typeof window !== 'undefined' && localStorage.getItem('pulse_pending_checkout')) {
|
||||||
router.push(returnTo)
|
router.push('/welcome')
|
||||||
|
} else {
|
||||||
|
const returnTo = searchParams.get('returnTo') || '/'
|
||||||
|
router.push(returnTo)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
setError(authMessageFromErrorType('invalid'))
|
setError(authMessageFromErrorType('invalid'))
|
||||||
}
|
}
|
||||||
|
|||||||
66
app/page.tsx
66
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,7 +414,26 @@ export default function HomePage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<SiteList sites={sites} loading={sitesLoading} onDelete={handleDelete} />
|
{!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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(sitesLoading || sites.length > 0) && (
|
||||||
|
<SiteList sites={sites} loading={sitesLoading} onDelete={handleDelete} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,9 +8,10 @@ import { toast } from '@ciphera-net/ui'
|
|||||||
import { getAuthErrorMessage } from '@/lib/utils/authErrors'
|
import { getAuthErrorMessage } from '@/lib/utils/authErrors'
|
||||||
import { LoadingOverlay } from '@ciphera-net/ui'
|
import { LoadingOverlay } from '@ciphera-net/ui'
|
||||||
import VerificationModal from '@/components/sites/VerificationModal'
|
import VerificationModal from '@/components/sites/VerificationModal'
|
||||||
|
import ScriptSetupBlock from '@/components/sites/ScriptSetupBlock'
|
||||||
import { PasswordInput } from '@ciphera-net/ui'
|
import { PasswordInput } from '@ciphera-net/ui'
|
||||||
import { Select, Modal, Button } 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 { generatePrivacySnippet } from '@/lib/utils/privacySnippet'
|
||||||
import { motion, AnimatePresence } from 'framer-motion'
|
import { motion, AnimatePresence } from 'framer-motion'
|
||||||
import { useAuth } from '@/lib/auth/context'
|
import { useAuth } from '@/lib/auth/context'
|
||||||
@@ -69,7 +70,6 @@ export default function SiteSettingsPage() {
|
|||||||
// Bot and noise filtering
|
// Bot and noise filtering
|
||||||
filter_bots: true
|
filter_bots: true
|
||||||
})
|
})
|
||||||
const [scriptCopied, setScriptCopied] = useState(false)
|
|
||||||
const [linkCopied, setLinkCopied] = useState(false)
|
const [linkCopied, setLinkCopied] = useState(false)
|
||||||
const [snippetCopied, setSnippetCopied] = useState(false)
|
const [snippetCopied, setSnippetCopied] = useState(false)
|
||||||
const [showVerificationModal, setShowVerificationModal] = useState(false)
|
const [showVerificationModal, setShowVerificationModal] = useState(false)
|
||||||
@@ -266,14 +266,6 @@ export default function SiteSettingsPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const copyScript = () => {
|
|
||||||
const script = `<script defer data-domain="${site?.domain}" data-api="${API_URL}" src="${APP_URL}/script.js"></script>`
|
|
||||||
navigator.clipboard.writeText(script)
|
|
||||||
setScriptCopied(true)
|
|
||||||
toast.success('Script copied to clipboard')
|
|
||||||
setTimeout(() => setScriptCopied(false), 2000)
|
|
||||||
}
|
|
||||||
|
|
||||||
const copyLink = () => {
|
const copyLink = () => {
|
||||||
const link = `${APP_URL}/share/${siteId}`
|
const link = `${APP_URL}/share/${siteId}`
|
||||||
navigator.clipboard.writeText(link)
|
navigator.clipboard.writeText(link)
|
||||||
@@ -443,23 +435,15 @@ export default function SiteSettingsPage() {
|
|||||||
<div className="pt-4 border-t border-neutral-100 dark:border-neutral-800">
|
<div className="pt-4 border-t border-neutral-100 dark:border-neutral-800">
|
||||||
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white mb-2">Tracking Script</h3>
|
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white mb-2">Tracking Script</h3>
|
||||||
<p className="text-sm text-neutral-500 dark:text-neutral-400 mb-4">
|
<p className="text-sm text-neutral-500 dark:text-neutral-400 mb-4">
|
||||||
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.
|
||||||
</p>
|
</p>
|
||||||
<div className="bg-neutral-100 dark:bg-neutral-800 rounded-lg p-4 mb-4 relative group">
|
<ScriptSetupBlock
|
||||||
<code className="text-sm text-neutral-900 dark:text-white break-all font-mono block">
|
site={{ domain: site.domain, name: site.name }}
|
||||||
{`<script defer data-domain="${site.domain}" data-api="${API_URL}" src="${APP_URL}/script.js"></script>`}
|
showFrameworkPicker
|
||||||
</code>
|
className="mb-4"
|
||||||
<button
|
/>
|
||||||
type="button"
|
|
||||||
onClick={copyScript}
|
|
||||||
className="absolute top-2 right-2 p-2 bg-white dark:bg-neutral-700 rounded-lg shadow-sm opacity-0 group-hover:opacity-100 transition-opacity"
|
|
||||||
title="Copy Script"
|
|
||||||
>
|
|
||||||
{scriptCopied ? <CheckIcon className="w-4 h-4 text-green-500" /> : <svg className="w-4 h-4 text-neutral-500" fill="none" viewBox="0 0 24 24" stroke="currentColor"><rect x="9" y="9" width="13" height="13" rx="2" ry="2" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" /><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" /></svg>}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4 mt-4">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setShowVerificationModal(true)}
|
onClick={() => setShowVerificationModal(true)}
|
||||||
|
|||||||
14
app/sites/new/layout.tsx
Normal file
14
app/sites/new/layout.tsx
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import type { Metadata } from 'next'
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: 'Create site | Pulse',
|
||||||
|
description: 'Add a new site to start collecting privacy-friendly analytics.',
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function NewSiteLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode
|
||||||
|
}) {
|
||||||
|
return children
|
||||||
|
}
|
||||||
@@ -2,11 +2,18 @@
|
|||||||
|
|
||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
import { createSite, listSites } from '@/lib/api/sites'
|
import Link from 'next/link'
|
||||||
|
import { createSite, listSites, getSite, type Site } from '@/lib/api/sites'
|
||||||
import { getSubscription } from '@/lib/api/billing'
|
import { getSubscription } from '@/lib/api/billing'
|
||||||
|
import { trackSiteCreatedFromDashboard, trackSiteCreatedScriptCopied } from '@/lib/welcomeAnalytics'
|
||||||
import { toast } from '@ciphera-net/ui'
|
import { toast } from '@ciphera-net/ui'
|
||||||
import { getAuthErrorMessage } from '@/lib/utils/authErrors'
|
import { getAuthErrorMessage } from '@/lib/utils/authErrors'
|
||||||
import { Button, Input } from '@ciphera-net/ui'
|
import { Button, Input } from '@ciphera-net/ui'
|
||||||
|
import { CheckCircleIcon } from '@ciphera-net/ui'
|
||||||
|
import ScriptSetupBlock from '@/components/sites/ScriptSetupBlock'
|
||||||
|
import VerificationModal from '@/components/sites/VerificationModal'
|
||||||
|
|
||||||
|
const LAST_CREATED_SITE_KEY = 'pulse_last_created_site'
|
||||||
|
|
||||||
export default function NewSitePage() {
|
export default function NewSitePage() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -15,6 +22,31 @@ export default function NewSitePage() {
|
|||||||
name: '',
|
name: '',
|
||||||
domain: '',
|
domain: '',
|
||||||
})
|
})
|
||||||
|
const [createdSite, setCreatedSite] = useState<Site | null>(null)
|
||||||
|
const [showVerificationModal, setShowVerificationModal] = useState(false)
|
||||||
|
const [atLimit, setAtLimit] = useState(false)
|
||||||
|
const [limitsChecked, setLimitsChecked] = useState(false)
|
||||||
|
|
||||||
|
// * Restore step 2 from sessionStorage after refresh (e.g. pulse_last_created_site = { id } )
|
||||||
|
useEffect(() => {
|
||||||
|
if (createdSite || typeof window === 'undefined') return
|
||||||
|
try {
|
||||||
|
const raw = sessionStorage.getItem(LAST_CREATED_SITE_KEY)
|
||||||
|
if (!raw) return
|
||||||
|
const { id } = JSON.parse(raw) as { id?: string }
|
||||||
|
if (!id) return
|
||||||
|
getSite(id)
|
||||||
|
.then((site) => {
|
||||||
|
setCreatedSite(site)
|
||||||
|
setFormData({ name: site.name, domain: site.domain })
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
sessionStorage.removeItem(LAST_CREATED_SITE_KEY)
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
sessionStorage.removeItem(LAST_CREATED_SITE_KEY)
|
||||||
|
}
|
||||||
|
}, [createdSite])
|
||||||
|
|
||||||
// * Check for plan limits on mount
|
// * Check for plan limits on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -26,12 +58,14 @@ export default function NewSitePage() {
|
|||||||
])
|
])
|
||||||
|
|
||||||
if (subscription?.plan_id === 'solo' && sites.length >= 1) {
|
if (subscription?.plan_id === 'solo' && sites.length >= 1) {
|
||||||
|
setAtLimit(true)
|
||||||
toast.error('Solo plan limit reached (1 site). Please upgrade to add more sites.')
|
toast.error('Solo plan limit reached (1 site). Please upgrade to add more sites.')
|
||||||
router.replace('/')
|
router.replace('/')
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Ignore errors here, let the backend handle the hard check on submit
|
|
||||||
console.error('Failed to check limits', error)
|
console.error('Failed to check limits', error)
|
||||||
|
} finally {
|
||||||
|
setLimitsChecked(true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,20 +79,108 @@ export default function NewSitePage() {
|
|||||||
try {
|
try {
|
||||||
const site = await createSite(formData)
|
const site = await createSite(formData)
|
||||||
toast.success('Site created successfully')
|
toast.success('Site created successfully')
|
||||||
router.push(`/sites/${site.id}`)
|
setCreatedSite(site)
|
||||||
} catch (error: any) {
|
trackSiteCreatedFromDashboard()
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
sessionStorage.setItem(LAST_CREATED_SITE_KEY, JSON.stringify({ id: site.id }))
|
||||||
|
}
|
||||||
|
} catch (error: unknown) {
|
||||||
toast.error(getAuthErrorMessage(error) || 'Failed to create site: ' + ((error as Error)?.message || 'Unknown error'))
|
toast.error(getAuthErrorMessage(error) || 'Failed to create site: ' + ((error as Error)?.message || 'Unknown error'))
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleBackToForm = () => {
|
||||||
|
setCreatedSite(null)
|
||||||
|
if (typeof window !== 'undefined') sessionStorage.removeItem(LAST_CREATED_SITE_KEY)
|
||||||
|
}
|
||||||
|
|
||||||
|
const goToDashboard = () => {
|
||||||
|
router.refresh()
|
||||||
|
router.push('/')
|
||||||
|
}
|
||||||
|
|
||||||
|
// * Step 2: Framework picker + script (same as /welcome after adding first site)
|
||||||
|
if (createdSite) {
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto px-4 py-8 max-w-2xl">
|
||||||
|
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-8">
|
||||||
|
<div className="text-center">
|
||||||
|
<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-6">
|
||||||
|
<CheckCircleIcon className="h-7 w-7" />
|
||||||
|
</div>
|
||||||
|
<h2 className="text-2xl font-bold text-neutral-900 dark:text-white">
|
||||||
|
Site created
|
||||||
|
</h2>
|
||||||
|
<p className="mt-2 text-neutral-600 dark:text-neutral-400">
|
||||||
|
Add the script to your site to start collecting data.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 pt-6 border-t border-neutral-200 dark:border-neutral-700">
|
||||||
|
<ScriptSetupBlock
|
||||||
|
site={{ domain: createdSite.domain, name: createdSite.name }}
|
||||||
|
onScriptCopy={trackSiteCreatedScriptCopied}
|
||||||
|
showFrameworkPicker
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 flex flex-wrap items-center justify-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowVerificationModal(true)}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-white dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 text-neutral-700 dark:text-neutral-300 rounded-xl hover:bg-neutral-50 dark:hover:bg-neutral-700 transition-all text-sm font-medium focus:outline-none focus:ring-2 focus:ring-brand-orange focus:ring-offset-2"
|
||||||
|
>
|
||||||
|
<span className="text-brand-orange">Verify installation</span>
|
||||||
|
</button>
|
||||||
|
<p className="text-xs text-neutral-500 dark:text-neutral-400">
|
||||||
|
Check if your site is sending data correctly.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 flex flex-wrap justify-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleBackToForm}
|
||||||
|
className="text-sm text-neutral-500 dark:text-neutral-400 hover:text-neutral-700 dark:hover:text-neutral-300 underline"
|
||||||
|
>
|
||||||
|
Edit site details
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-8 flex flex-col sm:flex-row gap-3 justify-center">
|
||||||
|
<Button variant="primary" onClick={goToDashboard} className="min-w-[160px]">
|
||||||
|
Back to dashboard
|
||||||
|
</Button>
|
||||||
|
<Button variant="secondary" onClick={() => router.push(`/sites/${createdSite.id}`)} className="min-w-[160px]">
|
||||||
|
View {createdSite.name}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<VerificationModal
|
||||||
|
isOpen={showVerificationModal}
|
||||||
|
onClose={() => setShowVerificationModal(false)}
|
||||||
|
site={createdSite}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// * Step 1: Name & domain form
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto px-4 py-8 max-w-2xl">
|
<div className="container mx-auto px-4 py-8 max-w-2xl">
|
||||||
<h1 className="text-2xl font-bold mb-8 text-neutral-900 dark:text-white">
|
<h1 className="text-2xl font-bold mb-8 text-neutral-900 dark:text-white">
|
||||||
Create New Site
|
Create New Site
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
|
{atLimit && limitsChecked && (
|
||||||
|
<p className="mb-4 text-sm text-amber-600 dark:text-amber-400">
|
||||||
|
Plan limit reached. Upgrade to add more sites.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6">
|
<form onSubmit={handleSubmit} className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6">
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<label htmlFor="name" className="block text-sm font-medium mb-2 text-neutral-900 dark:text-white">
|
<label htmlFor="name" className="block text-sm font-medium mb-2 text-neutral-900 dark:text-white">
|
||||||
@@ -92,7 +214,7 @@ export default function NewSitePage() {
|
|||||||
<div className="flex gap-4">
|
<div className="flex gap-4">
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={loading}
|
disabled={loading || atLimit}
|
||||||
isLoading={loading}
|
isLoading={loading}
|
||||||
>
|
>
|
||||||
Create Site
|
Create Site
|
||||||
|
|||||||
14
app/welcome/layout.tsx
Normal file
14
app/welcome/layout.tsx
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import type { Metadata } from 'next'
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: 'Welcome | Pulse',
|
||||||
|
description: 'Set up your Pulse workspace and add your first site.',
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function WelcomeLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode
|
||||||
|
}) {
|
||||||
|
return children
|
||||||
|
}
|
||||||
727
app/welcome/page.tsx
Normal file
727
app/welcome/page.tsx
Normal file
@@ -0,0 +1,727 @@
|
|||||||
|
'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,
|
||||||
|
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 { getAuthErrorMessage } from '@/lib/utils/authErrors'
|
||||||
|
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,
|
||||||
|
GlobeIcon,
|
||||||
|
ZapIcon,
|
||||||
|
PlusIcon,
|
||||||
|
} from '@ciphera-net/ui'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import ScriptSetupBlock from '@/components/sites/ScriptSetupBlock'
|
||||||
|
|
||||||
|
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()
|
||||||
|
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<Site | null>(null)
|
||||||
|
|
||||||
|
const [redirectingCheckout, setRedirectingCheckout] = useState(false)
|
||||||
|
const [hadPendingCheckout, setHadPendingCheckout] = useState<boolean | null>(null)
|
||||||
|
const [dismissedPendingCheckout, setDismissedPendingCheckout] = useState(false)
|
||||||
|
|
||||||
|
const [organizations, setOrganizations] = useState<OrganizationMember[] | null>(null)
|
||||||
|
const [orgsLoading, setOrgsLoading] = useState(false)
|
||||||
|
const [switchingOrgId, setSwitchingOrgId] = useState<string | null>(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 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<HTMLInputElement>) => {
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
trackWelcomeWorkspaceCreated(!!(typeof window !== 'undefined' && localStorage.getItem('pulse_pending_checkout')))
|
||||||
|
setStep(3)
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const { message, suggestSlug } = getOrgErrorMessage(err, orgSlug, 'Failed to create workspace')
|
||||||
|
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 <LoadingOverlay logoSrc="/pulse_icon_no_margins.png" title="Creating your workspace..." />
|
||||||
|
}
|
||||||
|
|
||||||
|
if (switchingOrgId) {
|
||||||
|
return <LoadingOverlay logoSrc="/pulse_icon_no_margins.png" title="Switching workspace..." />
|
||||||
|
}
|
||||||
|
|
||||||
|
if (redirectingCheckout || (planLoading && step === 3)) {
|
||||||
|
return (
|
||||||
|
<LoadingOverlay
|
||||||
|
logoSrc="/pulse_icon_no_margins.png"
|
||||||
|
title={redirectingCheckout ? 'Taking you to checkout...' : 'Preparing your plan...'}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<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="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) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className={`h-1.5 rounded-full transition-all duration-300 ${
|
||||||
|
i + 1 <= step
|
||||||
|
? 'bg-brand-orange w-8'
|
||||||
|
: 'bg-neutral-200 dark:bg-neutral-700 w-6'
|
||||||
|
}`}
|
||||||
|
aria-current={i + 1 === step ? 'step' : undefined}
|
||||||
|
aria-label={`Step ${i + 1} of ${TOTAL_STEPS}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AnimatePresence mode="wait">
|
||||||
|
{step === 1 && (
|
||||||
|
<motion.div
|
||||||
|
key="step1"
|
||||||
|
initial={{ opacity: 0, y: 12 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, y: -12 }}
|
||||||
|
transition={{ duration: 0.25 }}
|
||||||
|
className={cardClass}
|
||||||
|
>
|
||||||
|
{orgsLoading ? (
|
||||||
|
<div className="text-center py-8">
|
||||||
|
<p className="text-neutral-600 dark:text-neutral-400">Loading your workspaces...</p>
|
||||||
|
</div>
|
||||||
|
) : organizations && organizations.length > 0 ? (
|
||||||
|
<>
|
||||||
|
<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">
|
||||||
|
<BarChartIcon className="h-7 w-7" />
|
||||||
|
</div>
|
||||||
|
<h2 className="text-xl font-bold text-neutral-900 dark:text-white">
|
||||||
|
Choose your workspace
|
||||||
|
</h2>
|
||||||
|
<p className="mt-1 text-sm text-neutral-600 dark:text-neutral-400">
|
||||||
|
Continue with an existing workspace or create a new one.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2 mb-6">
|
||||||
|
{organizations.map((org) => (
|
||||||
|
<button
|
||||||
|
key={org.organization_id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleSelectWorkspace(org)}
|
||||||
|
disabled={!!switchingOrgId}
|
||||||
|
className="w-full flex items-center justify-between gap-3 rounded-xl border border-neutral-200 dark:border-neutral-700 bg-neutral-50 dark:bg-neutral-800/50 hover:bg-neutral-100 dark:hover:bg-neutral-800 hover:border-brand-orange/50 px-4 py-3 text-left transition-colors disabled:opacity-60"
|
||||||
|
>
|
||||||
|
<span className="font-medium text-neutral-900 dark:text-white">
|
||||||
|
{org.organization_name || 'Workspace'}
|
||||||
|
</span>
|
||||||
|
{user?.org_id === org.organization_id && (
|
||||||
|
<span className="text-xs text-neutral-500 dark:text-neutral-400">Current</span>
|
||||||
|
)}
|
||||||
|
<ArrowRightIcon className="h-4 w-4 text-neutral-400 flex-shrink-0" />
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
className="w-full"
|
||||||
|
onClick={handleCreateNewWorkspace}
|
||||||
|
>
|
||||||
|
<PlusIcon className="h-4 w-4 mr-2" />
|
||||||
|
Create a new workspace
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="mx-auto flex h-14 w-14 items-center justify-center rounded-full bg-brand-orange/10 text-brand-orange mb-6">
|
||||||
|
<ZapIcon className="h-7 w-7" />
|
||||||
|
</div>
|
||||||
|
<h1 className="text-2xl font-bold text-neutral-900 dark:text-white">
|
||||||
|
Welcome to Pulse
|
||||||
|
</h1>
|
||||||
|
<p className="mt-2 text-neutral-600 dark:text-neutral-400">
|
||||||
|
Privacy-first analytics in a few steps. No credit card required to start.
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="primary"
|
||||||
|
className="mt-8 w-full sm:w-auto min-w-[180px]"
|
||||||
|
onClick={() => setStep(2)}
|
||||||
|
>
|
||||||
|
Get started
|
||||||
|
<ArrowRightIcon className="ml-2 h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 2 && (
|
||||||
|
<motion.div
|
||||||
|
key="step2"
|
||||||
|
initial={{ opacity: 0, y: 12 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, y: -12 }}
|
||||||
|
transition={{ duration: 0.25 }}
|
||||||
|
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="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" />
|
||||||
|
</div>
|
||||||
|
<h2 className="text-xl font-bold text-neutral-900 dark:text-white">
|
||||||
|
Name your workspace
|
||||||
|
</h2>
|
||||||
|
<p className="mt-1 text-sm text-neutral-600 dark:text-neutral-400">
|
||||||
|
You can change this later in settings.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<form onSubmit={handleWorkspaceSubmit} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="welcome-org-name" className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
|
||||||
|
Workspace name
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id="welcome-org-name"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
placeholder="e.g. Acme Corp"
|
||||||
|
value={orgName}
|
||||||
|
onChange={handleNameChange}
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="welcome-org-slug" className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
|
||||||
|
URL slug
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id="welcome-org-slug"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
placeholder="acme-corp"
|
||||||
|
value={orgSlug}
|
||||||
|
onChange={(e) => setOrgSlug(e.target.value)}
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
<p className="mt-1 text-xs text-neutral-500 dark:text-neutral-400">
|
||||||
|
Used in your workspace URL.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{orgError && (
|
||||||
|
<p className="text-sm text-red-500 dark:text-red-400">{orgError}</p>
|
||||||
|
)}
|
||||||
|
<Button type="submit" variant="primary" className="w-full" disabled={orgLoading}>
|
||||||
|
Continue
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 3 && (
|
||||||
|
<motion.div
|
||||||
|
key="step3"
|
||||||
|
initial={{ opacity: 0, y: 12 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, y: -12 }}
|
||||||
|
transition={{ duration: 0.25 }}
|
||||||
|
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="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" />
|
||||||
|
</div>
|
||||||
|
<h2 className="text-xl font-bold text-neutral-900 dark:text-white">
|
||||||
|
{showPendingCheckoutInStep3 ? 'Complete your plan' : "You're on the free plan"}
|
||||||
|
</h2>
|
||||||
|
<p className="mt-1 text-sm text-neutral-600 dark:text-neutral-400">
|
||||||
|
{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.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{planError && (
|
||||||
|
<p className="text-sm text-red-500 dark:text-red-400 mb-4 text-center">{planError}</p>
|
||||||
|
)}
|
||||||
|
<div className="flex flex-col sm:flex-row gap-3 justify-center">
|
||||||
|
{showPendingCheckoutInStep3 ? (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
className="w-full sm:w-auto"
|
||||||
|
onClick={handlePlanContinue}
|
||||||
|
disabled={planLoading}
|
||||||
|
>
|
||||||
|
Continue to checkout
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
className="w-full sm:w-auto"
|
||||||
|
onClick={handlePlanSkip}
|
||||||
|
disabled={planLoading}
|
||||||
|
>
|
||||||
|
Stay on free plan
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
className="w-full sm:w-auto"
|
||||||
|
onClick={() => setStep(4)}
|
||||||
|
>
|
||||||
|
Continue
|
||||||
|
<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>
|
||||||
|
{showPendingCheckoutInStep3 && (
|
||||||
|
<p className="mt-4 text-center">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => router.push('/pricing')}
|
||||||
|
className="text-sm text-brand-orange hover:underline"
|
||||||
|
>
|
||||||
|
Choose a different plan
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 4 && (
|
||||||
|
<motion.div
|
||||||
|
key="step4"
|
||||||
|
initial={{ opacity: 0, y: 12 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, y: -12 }}
|
||||||
|
transition={{ duration: 0.25 }}
|
||||||
|
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="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" />
|
||||||
|
</div>
|
||||||
|
<h2 className="text-xl font-bold text-neutral-900 dark:text-white">
|
||||||
|
Add your first site
|
||||||
|
</h2>
|
||||||
|
<p className="mt-1 text-sm text-neutral-600 dark:text-neutral-400">
|
||||||
|
Optional. You can add sites later from the dashboard.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<form onSubmit={handleAddSite} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="welcome-site-name" className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
|
||||||
|
Site name
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id="welcome-site-name"
|
||||||
|
type="text"
|
||||||
|
placeholder="My Website"
|
||||||
|
value={siteName}
|
||||||
|
onChange={(e) => setSiteName(e.target.value)}
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="welcome-site-domain" className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
|
||||||
|
Domain
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id="welcome-site-domain"
|
||||||
|
type="text"
|
||||||
|
placeholder="example.com"
|
||||||
|
value={siteDomain}
|
||||||
|
onChange={(e) => setSiteDomain(e.target.value.toLowerCase().trim())}
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
<p className="mt-1 text-xs text-neutral-500 dark:text-neutral-400">
|
||||||
|
Without http:// or https://
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{siteError && (
|
||||||
|
<p className="text-sm text-red-500 dark:text-red-400">{siteError}</p>
|
||||||
|
)}
|
||||||
|
<div className="flex flex-col sm:flex-row gap-3">
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="primary"
|
||||||
|
className="flex-1"
|
||||||
|
disabled={siteLoading || !siteName.trim() || !siteDomain.trim()}
|
||||||
|
isLoading={siteLoading}
|
||||||
|
>
|
||||||
|
Add site
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
className="flex-1"
|
||||||
|
onClick={handleSkipSite}
|
||||||
|
disabled={siteLoading}
|
||||||
|
>
|
||||||
|
Skip for now
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 5 && (
|
||||||
|
<motion.div
|
||||||
|
key="step5"
|
||||||
|
initial={{ opacity: 0, y: 12 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, y: -12 }}
|
||||||
|
transition={{ duration: 0.25 }}
|
||||||
|
className={cardClass}
|
||||||
|
>
|
||||||
|
<div className="text-center">
|
||||||
|
<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-6">
|
||||||
|
<CheckCircleIcon className="h-7 w-7" />
|
||||||
|
</div>
|
||||||
|
<h2 className="text-2xl font-bold text-neutral-900 dark:text-white">
|
||||||
|
You're all set
|
||||||
|
</h2>
|
||||||
|
<p className="mt-2 text-neutral-600 dark:text-neutral-400">
|
||||||
|
{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.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{createdSite && (
|
||||||
|
<div className="mt-6 pt-6 border-t border-neutral-200 dark:border-neutral-700">
|
||||||
|
<ScriptSetupBlock
|
||||||
|
site={{ domain: createdSite.domain, name: createdSite.name }}
|
||||||
|
showFrameworkPicker
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-8 flex flex-col sm:flex-row gap-3 justify-center">
|
||||||
|
<Button variant="primary" onClick={goToDashboard} className="min-w-[160px]">
|
||||||
|
Go to dashboard
|
||||||
|
</Button>
|
||||||
|
{createdSite && (
|
||||||
|
<Button variant="secondary" onClick={goToSite} className="min-w-[160px]">
|
||||||
|
View {createdSite.name}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function WelcomePage() {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={<LoadingOverlay logoSrc="/pulse_icon_no_margins.png" title="Pulse" />}>
|
||||||
|
<WelcomeContent />
|
||||||
|
</Suspense>
|
||||||
|
)
|
||||||
|
}
|
||||||
124
components/sites/ScriptSetupBlock.tsx
Normal file
124
components/sites/ScriptSetupBlock.tsx
Normal file
@@ -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<string | null>(null)
|
||||||
|
const [scriptCopied, setScriptCopied] = useState(false)
|
||||||
|
|
||||||
|
const copyScript = useCallback(() => {
|
||||||
|
const script = `<script defer data-domain="${site.domain}" data-api="${API_URL}" src="${APP_URL}/script.js"></script>`
|
||||||
|
navigator.clipboard.writeText(script)
|
||||||
|
setScriptCopied(true)
|
||||||
|
toast.success('Script copied to clipboard')
|
||||||
|
onScriptCopy?.()
|
||||||
|
setTimeout(() => setScriptCopied(false), 2000)
|
||||||
|
}, [site.domain, onScriptCopy])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={className}>
|
||||||
|
{showFrameworkPicker && (
|
||||||
|
<>
|
||||||
|
<h3 className="text-sm font-semibold text-neutral-900 dark:text-white mb-1">
|
||||||
|
Add the script to your site
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-neutral-500 dark:text-neutral-400 mb-3">
|
||||||
|
Choose your framework for setup instructions.
|
||||||
|
</p>
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2 mb-4">
|
||||||
|
{POPULAR_INTEGRATIONS.map((int) => (
|
||||||
|
<button
|
||||||
|
key={int.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSelectedIntegrationSlug(selectedIntegrationSlug === int.id ? null : int.id)}
|
||||||
|
className={`flex items-center gap-2 rounded-lg border px-3 py-2.5 text-left text-sm transition-colors ${
|
||||||
|
selectedIntegrationSlug === int.id
|
||||||
|
? 'border-brand-orange bg-brand-orange/10 text-brand-orange'
|
||||||
|
: 'border-neutral-200 dark:border-neutral-700 bg-neutral-50 dark:bg-neutral-800/50 hover:bg-neutral-100 dark:hover:bg-neutral-800 text-neutral-700 dark:text-neutral-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="[&_svg]:h-4 [&_svg]:w-4 [&_svg]:shrink-0 flex items-center justify-center">
|
||||||
|
{int.icon}
|
||||||
|
</span>
|
||||||
|
<span className="truncate font-medium">{int.name}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-neutral-500 dark:text-neutral-400 mb-2">
|
||||||
|
<Link href="/integrations" target="_blank" rel="noopener noreferrer" className="text-brand-orange hover:underline">
|
||||||
|
View all integrations →
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="rounded-xl bg-neutral-100 dark:bg-neutral-800 p-4 relative group">
|
||||||
|
<code className="text-xs text-neutral-900 dark:text-white break-all font-mono block pr-10">
|
||||||
|
{`<script defer data-domain="${site.domain}" data-api="${API_URL}" src="${APP_URL}/script.js"></script>`}
|
||||||
|
</code>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={copyScript}
|
||||||
|
className="absolute top-2 right-2 p-2 bg-white dark:bg-neutral-700 rounded-lg shadow-sm hover:bg-neutral-50 dark:hover:bg-neutral-600 transition-colors"
|
||||||
|
title="Copy script"
|
||||||
|
aria-label={scriptCopied ? 'Copied' : 'Copy script to clipboard'}
|
||||||
|
>
|
||||||
|
{scriptCopied ? (
|
||||||
|
<CheckIcon className="w-4 h-4 text-green-500" />
|
||||||
|
) : (
|
||||||
|
<svg className="w-4 h-4 text-neutral-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<rect x="9" y="9" width="13" height="13" rx="2" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
<path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showFrameworkPicker && selectedIntegrationSlug && getIntegration(selectedIntegrationSlug) && (
|
||||||
|
<p className="mt-3 text-xs">
|
||||||
|
<Link
|
||||||
|
href={`/integrations/${selectedIntegrationSlug}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-brand-orange hover:underline"
|
||||||
|
>
|
||||||
|
See full {getIntegration(selectedIntegrationSlug)!.name} guide →
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -130,18 +130,15 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const checkOrg = async () => {
|
const checkOrg = async () => {
|
||||||
if (!loading && user) {
|
if (!loading && user) {
|
||||||
// * If we are on onboarding, skip check
|
|
||||||
if (pathname?.startsWith('/onboarding')) return
|
if (pathname?.startsWith('/onboarding')) return
|
||||||
|
|
||||||
// * If we are processing auth callback, skip check to avoid redirect loops
|
|
||||||
if (pathname?.startsWith('/auth/callback')) return
|
if (pathname?.startsWith('/auth/callback')) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const organizations = await getUserOrganizations()
|
const organizations = await getUserOrganizations()
|
||||||
|
|
||||||
if (organizations.length === 0) {
|
if (organizations.length === 0) {
|
||||||
// * No organizations -> Redirect to Onboarding
|
if (pathname?.startsWith('/welcome')) return
|
||||||
router.push('/onboarding')
|
router.push('/welcome')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
87
lib/welcomeAnalytics.ts
Normal file
87
lib/welcomeAnalytics.ts
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
/**
|
||||||
|
* 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_selected'
|
||||||
|
| 'welcome_workspace_created'
|
||||||
|
| 'welcome_plan_continue'
|
||||||
|
| 'welcome_plan_skip'
|
||||||
|
| 'welcome_site_added'
|
||||||
|
| 'welcome_site_skipped'
|
||||||
|
| 'welcome_completed'
|
||||||
|
| 'site_created_from_dashboard'
|
||||||
|
| 'site_created_script_copied'
|
||||||
|
|
||||||
|
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 trackWelcomeWorkspaceSelected() {
|
||||||
|
emit('welcome_workspace_selected')
|
||||||
|
}
|
||||||
|
|
||||||
|
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 })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function trackSiteCreatedFromDashboard() {
|
||||||
|
emit('site_created_from_dashboard')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function trackSiteCreatedScriptCopied() {
|
||||||
|
emit('site_created_script_copied')
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user