diff --git a/app/auth/callback/page.tsx b/app/auth/callback/page.tsx index db02ff4..631d19e 100644 --- a/app/auth/callback/page.tsx +++ b/app/auth/callback/page.tsx @@ -27,7 +27,7 @@ function AuthCallbackContent() { localStorage.removeItem('oauth_state') localStorage.removeItem('oauth_code_verifier') if (localStorage.getItem('pulse_pending_checkout')) { - router.push('/pricing') + router.push('/welcome') } else { router.push('/') } @@ -52,8 +52,12 @@ function AuthCallbackContent() { const result = await setSessionAction(token, refreshToken) if (result.success && result.user) { login(result.user) - const returnTo = searchParams.get('returnTo') || '/' - router.push(returnTo) + if (typeof window !== 'undefined' && localStorage.getItem('pulse_pending_checkout')) { + router.push('/welcome') + } else { + const returnTo = searchParams.get('returnTo') || '/' + router.push(returnTo) + } } else { setError(authMessageFromErrorType('invalid')) } diff --git a/app/page.tsx b/app/page.tsx index a620a5e..c8b58f0 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,7 +414,26 @@ 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. +

+ + + +
+ )} + + {(sitesLoading || sites.length > 0) && ( + + )}
) } diff --git a/app/sites/[id]/settings/page.tsx b/app/sites/[id]/settings/page.tsx index 8ea491f..32935e2 100644 --- a/app/sites/[id]/settings/page.tsx +++ b/app/sites/[id]/settings/page.tsx @@ -8,9 +8,10 @@ import { toast } from '@ciphera-net/ui' import { getAuthErrorMessage } from '@/lib/utils/authErrors' import { LoadingOverlay } from '@ciphera-net/ui' import VerificationModal from '@/components/sites/VerificationModal' +import ScriptSetupBlock from '@/components/sites/ScriptSetupBlock' import { PasswordInput } from '@ciphera-net/ui' import { Select, Modal, Button } from '@ciphera-net/ui' -import { APP_URL, API_URL } from '@/lib/api/client' +import { APP_URL } from '@/lib/api/client' import { generatePrivacySnippet } from '@/lib/utils/privacySnippet' import { motion, AnimatePresence } from 'framer-motion' import { useAuth } from '@/lib/auth/context' @@ -69,7 +70,6 @@ export default function SiteSettingsPage() { // Bot and noise filtering filter_bots: true }) - const [scriptCopied, setScriptCopied] = useState(false) const [linkCopied, setLinkCopied] = useState(false) const [snippetCopied, setSnippetCopied] = useState(false) const [showVerificationModal, setShowVerificationModal] = useState(false) @@ -266,14 +266,6 @@ export default function SiteSettingsPage() { } } - const copyScript = () => { - const script = `` - navigator.clipboard.writeText(script) - setScriptCopied(true) - toast.success('Script copied to clipboard') - setTimeout(() => setScriptCopied(false), 2000) - } - const copyLink = () => { const link = `${APP_URL}/share/${siteId}` navigator.clipboard.writeText(link) @@ -443,23 +435,15 @@ export default function SiteSettingsPage() {

Tracking Script

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

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

+ Check if your site is sending data correctly. +

+
+ +
+ +
+ +
+ + +
+
+ + setShowVerificationModal(false)} + site={createdSite} + /> +
+ ) + } + + // * Step 1: Name & domain form return (

Create New Site

+ {atLimit && limitsChecked && ( +

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

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

+ Add the script to your site +

+

+ Choose your framework for setup instructions. +

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

+ + View all integrations → + +

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

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

+ )} +
+ ) +} diff --git a/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/auth/context.tsx b/lib/auth/context.tsx index fc9fdad..bf4bcd4 100644 --- a/lib/auth/context.tsx +++ b/lib/auth/context.tsx @@ -130,18 +130,15 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { useEffect(() => { const checkOrg = async () => { if (!loading && user) { - // * If we are on onboarding, skip check if (pathname?.startsWith('/onboarding')) return - - // * If we are processing auth callback, skip check to avoid redirect loops if (pathname?.startsWith('/auth/callback')) return try { const organizations = await getUserOrganizations() - + if (organizations.length === 0) { - // * No organizations -> Redirect to Onboarding - router.push('/onboarding') + if (pathname?.startsWith('/welcome')) return + router.push('/welcome') return } diff --git a/lib/welcomeAnalytics.ts b/lib/welcomeAnalytics.ts new file mode 100644 index 0000000..5c2f2db --- /dev/null +++ b/lib/welcomeAnalytics.ts @@ -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 = {}) { + 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') +}