feat: implement custom events tracking and goals management in the dashboard

This commit is contained in:
Usman Baig
2026-02-04 14:33:06 +01:00
parent 0d16f3ba55
commit 90a743c170
7 changed files with 362 additions and 2 deletions

View File

@@ -17,6 +17,7 @@ import Locations from '@/components/dashboard/Locations'
import TechSpecs from '@/components/dashboard/TechSpecs'
import Chart from '@/components/dashboard/Chart'
import PerformanceStats from '@/components/dashboard/PerformanceStats'
import GoalStats from '@/components/dashboard/GoalStats'
export default function SiteDashboardPage() {
const { user } = useAuth()
@@ -46,6 +47,7 @@ export default function SiteDashboardPage() {
const [screenResolutions, setScreenResolutions] = useState<any[]>([])
const [performance, setPerformance] = useState<{ lcp: number, cls: number, inp: number }>({ lcp: 0, cls: 0, inp: 0 })
const [performanceByPage, setPerformanceByPage] = useState<PerformanceByPageStat[] | null>(null)
const [goalCounts, setGoalCounts] = useState<Array<{ event_name: string; count: number }>>([])
const [dateRange, setDateRange] = useState(getDateRange(30))
const [isDatePickerOpen, setIsDatePickerOpen] = useState(false)
const [isExportModalOpen, setIsExportModalOpen] = useState(false)
@@ -192,6 +194,7 @@ export default function SiteDashboardPage() {
setScreenResolutions(Array.isArray(data.screen_resolutions) ? data.screen_resolutions : [])
setPerformance(data.performance || { lcp: 0, cls: 0, inp: 0 })
setPerformanceByPage(data.performance_by_page ?? null)
setGoalCounts(Array.isArray(data.goal_counts) ? data.goal_counts : [])
} catch (error: unknown) {
toast.error(getAuthErrorMessage(error) || 'Failed to load data: ' + ((error as Error)?.message || 'Unknown error'))
} finally {
@@ -374,6 +377,10 @@ export default function SiteDashboardPage() {
/>
</div>
<div className="mb-8">
<GoalStats goalCounts={goalCounts} siteId={siteId} dateRange={dateRange} />
</div>
<DatePicker
isOpen={isDatePickerOpen}
onClose={() => setIsDatePickerOpen(false)}

View File

@@ -3,12 +3,13 @@
import { useEffect, useState } from 'react'
import { useParams, useRouter } from 'next/navigation'
import { getSite, updateSite, resetSiteData, deleteSite, type Site, type GeoDataLevel } from '@/lib/api/sites'
import { listGoals, createGoal, updateGoal, deleteGoal, type Goal } from '@/lib/api/goals'
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 { PasswordInput } from '@ciphera-net/ui'
import { Select } from '@ciphera-net/ui'
import { Select, Modal, Button } from '@ciphera-net/ui'
import { APP_URL, API_URL } from '@/lib/api/client'
import { generatePrivacySnippet } from '@/lib/utils/privacySnippet'
import { motion, AnimatePresence } from 'framer-motion'
@@ -49,7 +50,7 @@ export default function SiteSettingsPage() {
const [site, setSite] = useState<Site | null>(null)
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [activeTab, setActiveTab] = useState<'general' | 'visibility' | 'data'>('general')
const [activeTab, setActiveTab] = useState<'general' | 'visibility' | 'data' | 'goals'>('general')
const [formData, setFormData] = useState({
name: '',
@@ -73,11 +74,23 @@ export default function SiteSettingsPage() {
const [snippetCopied, setSnippetCopied] = useState(false)
const [showVerificationModal, setShowVerificationModal] = useState(false)
const [isPasswordEnabled, setIsPasswordEnabled] = useState(false)
const [goals, setGoals] = useState<Goal[]>([])
const [goalsLoading, setGoalsLoading] = useState(false)
const [goalModalOpen, setGoalModalOpen] = useState(false)
const [editingGoal, setEditingGoal] = useState<Goal | null>(null)
const [goalForm, setGoalForm] = useState({ name: '', event_name: '' })
const [goalSaving, setGoalSaving] = useState(false)
useEffect(() => {
loadSite()
}, [siteId])
useEffect(() => {
if (activeTab === 'goals' && siteId) {
loadGoals()
}
}, [activeTab, siteId])
const loadSite = async () => {
try {
setLoading(true)
@@ -112,6 +125,70 @@ export default function SiteSettingsPage() {
}
}
const loadGoals = async () => {
try {
setGoalsLoading(true)
const data = await listGoals(siteId)
setGoals(data ?? [])
} catch (e) {
toast.error(getAuthErrorMessage(e as Error) || 'Failed to load goals')
} finally {
setGoalsLoading(false)
}
}
const openAddGoal = () => {
setEditingGoal(null)
setGoalForm({ name: '', event_name: '' })
setGoalModalOpen(true)
}
const openEditGoal = (goal: Goal) => {
setEditingGoal(goal)
setGoalForm({ name: goal.name, event_name: goal.event_name })
setGoalModalOpen(true)
}
const handleGoalSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!goalForm.name.trim() || !goalForm.event_name.trim()) {
toast.error('Name and event name are required')
return
}
const eventName = goalForm.event_name.trim().toLowerCase().replace(/\s+/g, '_')
if (!/^[a-zA-Z0-9_]+$/.test(eventName)) {
toast.error('Event name can only contain letters, numbers, and underscores')
return
}
setGoalSaving(true)
try {
if (editingGoal) {
await updateGoal(siteId, editingGoal.id, { name: goalForm.name.trim(), event_name: eventName })
toast.success('Goal updated')
} else {
await createGoal(siteId, { name: goalForm.name.trim(), event_name: eventName })
toast.success('Goal created')
}
setGoalModalOpen(false)
loadGoals()
} catch (err) {
toast.error(getAuthErrorMessage(err as Error) || 'Failed to save goal')
} finally {
setGoalSaving(false)
}
}
const handleDeleteGoal = async (goal: Goal) => {
if (!confirm(`Delete goal "${goal.name}"?`)) return
try {
await deleteGoal(siteId, goal.id)
toast.success('Goal deleted')
loadGoals()
} catch (err) {
toast.error(getAuthErrorMessage(err as Error) || 'Failed to delete goal')
}
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setSaving(true)
@@ -260,6 +337,17 @@ export default function SiteSettingsPage() {
<SettingsIcon className="w-5 h-5" />
Data & Privacy
</button>
<button
onClick={() => setActiveTab('goals')}
className={`w-full flex items-center gap-3 px-4 py-3 text-sm font-medium rounded-xl transition-all duration-200 ${
activeTab === 'goals'
? 'bg-brand-orange/10 text-brand-orange'
: 'text-neutral-600 dark:text-neutral-400 hover:bg-neutral-100 dark:hover:bg-neutral-800'
}`}
>
<ZapIcon className="w-5 h-5" />
Goals & Events
</button>
</nav>
{/* Content Area */}
@@ -818,11 +906,109 @@ export default function SiteSettingsPage() {
</form>
</div>
)}
{activeTab === 'goals' && (
<div className="space-y-6">
<div>
<h2 className="text-xl font-semibold text-neutral-900 dark:text-white mb-1">Goals & Events</h2>
<p className="text-sm text-neutral-500 dark:text-neutral-400">
Define goals to label custom events (e.g. signup, purchase). Track with <code className="px-1.5 py-0.5 rounded bg-neutral-200 dark:bg-neutral-700 text-xs">pulse.track(&apos;event_name&apos;)</code> in your snippet.
</p>
</div>
{goalsLoading ? (
<div className="py-8 text-center text-neutral-500 dark:text-neutral-400">Loading goals</div>
) : (
<>
{canEdit && (
<Button onClick={openAddGoal} variant="primary">
Add goal
</Button>
)}
<div className="space-y-2">
{goals.length === 0 ? (
<div className="p-6 rounded-xl border border-neutral-200 dark:border-neutral-800 bg-neutral-50 dark:bg-neutral-900/50 text-center text-neutral-500 dark:text-neutral-400 text-sm">
No goals yet. Add a goal to give custom events a display name in the dashboard.
</div>
) : (
goals.map((goal) => (
<div
key={goal.id}
className="flex items-center justify-between py-3 px-4 rounded-xl border border-neutral-200 dark:border-neutral-800 bg-neutral-50/50 dark:bg-neutral-900/50"
>
<div>
<span className="font-medium text-neutral-900 dark:text-white">{goal.name}</span>
<span className="text-neutral-500 dark:text-neutral-400 text-sm ml-2">({goal.event_name})</span>
</div>
{canEdit && (
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => openEditGoal(goal)}
className="text-sm text-brand-orange hover:underline"
>
Edit
</button>
<button
type="button"
onClick={() => handleDeleteGoal(goal)}
className="text-sm text-red-600 dark:text-red-400 hover:underline"
>
Delete
</button>
</div>
)}
</div>
))
)}
</div>
</>
)}
</div>
)}
</motion.div>
</div>
</div>
</div>
<Modal
isOpen={goalModalOpen}
onClose={() => setGoalModalOpen(false)}
title={editingGoal ? 'Edit goal' : 'Add goal'}
>
<form onSubmit={handleGoalSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">Display name</label>
<input
type="text"
value={goalForm.name}
onChange={(e) => setGoalForm({ ...goalForm, name: e.target.value })}
placeholder="e.g. Signups"
className="w-full px-4 py-2 border border-neutral-200 dark:border-neutral-800 rounded-xl bg-white dark:bg-neutral-900 text-neutral-900 dark:text-white"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">Event name</label>
<input
type="text"
value={goalForm.event_name}
onChange={(e) => setGoalForm({ ...goalForm, event_name: e.target.value })}
placeholder="e.g. signup_click (letters, numbers, underscores only)"
className="w-full px-4 py-2 border border-neutral-200 dark:border-neutral-800 rounded-xl bg-white dark:bg-neutral-900 text-neutral-900 dark:text-white"
required
/>
</div>
<div className="flex justify-end gap-2 pt-2">
<Button type="button" variant="secondary" onClick={() => setGoalModalOpen(false)}>
Cancel
</Button>
<Button type="submit" variant="primary" disabled={goalSaving}>
{goalSaving ? 'Saving…' : editingGoal ? 'Update' : 'Create'}
</Button>
</div>
</form>
</Modal>
<VerificationModal
isOpen={showVerificationModal}
onClose={() => setShowVerificationModal(false)}