feat: integrate ScriptSetupBlock component for improved site setup instructions and tracking script functionality across pages
This commit is contained in:
@@ -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 = `<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 link = `${APP_URL}/share/${siteId}`
|
||||
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">
|
||||
<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">
|
||||
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>
|
||||
<div className="bg-neutral-100 dark:bg-neutral-800 rounded-lg p-4 mb-4 relative group">
|
||||
<code className="text-sm text-neutral-900 dark:text-white break-all font-mono block">
|
||||
{`<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 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>
|
||||
<ScriptSetupBlock
|
||||
site={{ domain: site.domain, name: site.name }}
|
||||
showFrameworkPicker
|
||||
className="mb-4"
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-4 mt-4">
|
||||
<button
|
||||
type="button"
|
||||
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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
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
|
||||
}
|
||||
@@ -21,8 +21,6 @@ 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 { API_URL, APP_URL } from '@/lib/api/client'
|
||||
import { integrations, getIntegration } from '@/lib/integrations'
|
||||
import {
|
||||
trackWelcomeStepView,
|
||||
trackWelcomeWorkspaceSelected,
|
||||
@@ -37,7 +35,6 @@ import { LoadingOverlay, Button, Input } from '@ciphera-net/ui'
|
||||
import { toast } from '@ciphera-net/ui'
|
||||
import {
|
||||
CheckCircleIcon,
|
||||
CheckIcon,
|
||||
ArrowRightIcon,
|
||||
ArrowLeftIcon,
|
||||
BarChartIcon,
|
||||
@@ -46,6 +43,7 @@ import {
|
||||
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'
|
||||
@@ -107,9 +105,6 @@ function WelcomeContent() {
|
||||
const [orgsLoading, setOrgsLoading] = useState(false)
|
||||
const [switchingOrgId, setSwitchingOrgId] = useState<string | null>(null)
|
||||
|
||||
const [selectedIntegrationSlug, setSelectedIntegrationSlug] = useState<string | null>(null)
|
||||
const [scriptCopied, setScriptCopied] = useState(false)
|
||||
|
||||
const setStep = useCallback(
|
||||
(next: number) => {
|
||||
const s = Math.min(Math.max(1, next), TOTAL_STEPS)
|
||||
@@ -270,17 +265,6 @@ function WelcomeContent() {
|
||||
}
|
||||
const goToSite = () => createdSite && router.push(`/sites/${createdSite.id}`)
|
||||
|
||||
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 popularIntegrations = integrations.filter((i) => i.category === 'framework').slice(0, 10)
|
||||
|
||||
const showPendingCheckoutInStep3 =
|
||||
hadPendingCheckout === true && !dismissedPendingCheckout
|
||||
|
||||
@@ -709,69 +693,10 @@ function WelcomeContent() {
|
||||
|
||||
{createdSite && (
|
||||
<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>
|
||||
|
||||
<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"
|
||||
>
|
||||
{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>
|
||||
)}
|
||||
<ScriptSetupBlock
|
||||
site={{ domain: createdSite.domain, name: createdSite.name }}
|
||||
showFrameworkPicker
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -13,6 +13,8 @@ export type WelcomeEventName =
|
||||
| 'welcome_site_added'
|
||||
| 'welcome_site_skipped'
|
||||
| 'welcome_completed'
|
||||
| 'site_created_from_dashboard'
|
||||
| 'site_created_script_copied'
|
||||
|
||||
export interface WelcomeEventPayload {
|
||||
event: WelcomeEventName
|
||||
@@ -75,3 +77,11 @@ export function trackWelcomeSiteSkipped() {
|
||||
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