feat: integrate ScriptSetupBlock component for improved site setup instructions and tracking script functionality across pages

This commit is contained in:
Usman Baig
2026-02-08 15:18:33 +01:00
parent 39e90f4f09
commit bd2aca7a76
7 changed files with 266 additions and 189 deletions

14
app/sites/new/layout.tsx Normal file
View 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
}

View File

@@ -1,18 +1,19 @@
'use client'
import { useState, useEffect, useCallback } from 'react'
import { useState, useEffect } from 'react'
import { useRouter } from 'next/navigation'
import Link from 'next/link'
import { createSite, listSites, type Site } from '@/lib/api/sites'
import { createSite, listSites, getSite, type Site } from '@/lib/api/sites'
import { getSubscription } from '@/lib/api/billing'
import { API_URL, APP_URL } from '@/lib/api/client'
import { integrations, getIntegration } from '@/lib/integrations'
import { trackSiteCreatedFromDashboard, trackSiteCreatedScriptCopied } from '@/lib/welcomeAnalytics'
import { toast } from '@ciphera-net/ui'
import { getAuthErrorMessage } from '@/lib/utils/authErrors'
import { Button, Input } from '@ciphera-net/ui'
import { CheckCircleIcon, CheckIcon } from '@ciphera-net/ui'
import { CheckCircleIcon } from '@ciphera-net/ui'
import ScriptSetupBlock from '@/components/sites/ScriptSetupBlock'
import VerificationModal from '@/components/sites/VerificationModal'
const popularIntegrations = integrations.filter((i) => i.category === 'framework').slice(0, 10)
const LAST_CREATED_SITE_KEY = 'pulse_last_created_site'
export default function NewSitePage() {
const router = useRouter()
@@ -22,8 +23,30 @@ export default function NewSitePage() {
domain: '',
})
const [createdSite, setCreatedSite] = useState<Site | null>(null)
const [selectedIntegrationSlug, setSelectedIntegrationSlug] = useState<string | null>(null)
const [scriptCopied, setScriptCopied] = useState(false)
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
useEffect(() => {
@@ -35,27 +58,20 @@ export default function NewSitePage() {
])
if (subscription?.plan_id === 'solo' && sites.length >= 1) {
setAtLimit(true)
toast.error('Solo plan limit reached (1 site). Please upgrade to add more sites.')
router.replace('/')
}
} catch (error) {
// Ignore errors here, let the backend handle the hard check on submit
console.error('Failed to check limits', error)
} finally {
setLimitsChecked(true)
}
}
checkLimits()
}, [router])
const copyScript = useCallback(() => {
if (!createdSite) return
const script = `<script defer data-domain="${createdSite.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)
}, [createdSite])
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setLoading(true)
@@ -64,6 +80,10 @@ export default function NewSitePage() {
const site = await createSite(formData)
toast.success('Site created successfully')
setCreatedSite(site)
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'))
} finally {
@@ -71,7 +91,17 @@ export default function NewSitePage() {
}
}
// * Step 2: Show framework picker + script (same as /welcome after adding first site)
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">
@@ -89,74 +119,38 @@ export default function NewSitePage() {
</div>
<div className="mt-6 pt-6 border-t border-neutral-200 dark:border-neutral-700">
<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">
{popularIntegrations.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>
<ScriptSetupBlock
site={{ domain: createdSite.domain, name: createdSite.name }}
onScriptCopy={trackSiteCreatedScriptCopied}
showFrameworkPicker
/>
</div>
<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="${createdSite.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>
{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 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={() => router.push('/')} className="min-w-[160px]">
<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]">
@@ -164,6 +158,12 @@ export default function NewSitePage() {
</Button>
</div>
</div>
<VerificationModal
isOpen={showVerificationModal}
onClose={() => setShowVerificationModal(false)}
site={createdSite}
/>
</div>
)
}
@@ -175,6 +175,12 @@ export default function NewSitePage() {
Create New Site
</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">
<div className="mb-4">
<label htmlFor="name" className="block text-sm font-medium mb-2 text-neutral-900 dark:text-white">
@@ -208,7 +214,7 @@ export default function NewSitePage() {
<div className="flex gap-4">
<Button
type="submit"
disabled={loading}
disabled={loading || atLimit}
isLoading={loading}
>
Create Site