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

@@ -57,6 +57,33 @@ export default function InstallationPage() {
</div>
</div>
</div>
<div className="w-full mt-16 text-center">
<h2 className="text-2xl font-bold mb-4 text-neutral-900 dark:text-white">Custom events (goals)</h2>
<p className="text-neutral-500 mb-6 max-w-xl mx-auto">
Track custom events (e.g. signup, purchase) with <code className="px-1.5 py-0.5 rounded bg-neutral-200 dark:bg-neutral-700 text-sm font-mono">pulse.track(&apos;event_name&apos;)</code>. Use letters, numbers, and underscores only. Define goals in your site Settings Goals & Events to see counts in the dashboard.
</p>
<div className="max-w-2xl mx-auto bg-[#1e1e1e] rounded-xl overflow-hidden shadow-2xl text-left border border-neutral-800">
<div className="flex items-center px-4 py-3 bg-[#252526] border-b border-neutral-800">
<div className="flex gap-2">
<div className="w-3 h-3 rounded-full bg-red-500/20" />
<div className="w-3 h-3 rounded-full bg-yellow-500/20" />
<div className="w-3 h-3 rounded-full bg-green-500/20" />
</div>
<span className="ml-4 text-xs text-neutral-500 font-mono">e.g. button click handler</span>
</div>
<div className="p-6 overflow-x-auto">
<code className="font-mono text-sm text-neutral-300">
<span className="text-purple-400">pulse</span>
<span className="text-white">.</span>
<span className="text-yellow-300">track</span>
<span className="text-white">(</span>
<span className="text-green-400">&apos;signup_click&apos;</span>
<span className="text-white">);</span>
</code>
</div>
</div>
</div>
</div>
</div>
)

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)}

View File

@@ -0,0 +1,51 @@
'use client'
import { formatNumber } from '@/lib/utils/format'
import type { GoalCountStat } from '@/lib/api/stats'
interface GoalStatsProps {
goalCounts: GoalCountStat[]
siteId: string
dateRange: { start: string; end: string }
}
const LIMIT = 10
export default function GoalStats({ goalCounts, siteId, dateRange }: GoalStatsProps) {
const list = (goalCounts || []).slice(0, LIMIT)
const hasData = list.length > 0
return (
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-xl p-6 h-full flex flex-col">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white">
Goals & Events
</h3>
</div>
<div className="space-y-2 flex-1 min-h-[200px]">
{hasData ? (
list.map((row) => (
<div
key={row.event_name}
className="flex items-center justify-between py-2 px-3 rounded-lg bg-neutral-50 dark:bg-neutral-800/50 hover:bg-neutral-100 dark:hover:bg-neutral-800 transition-colors"
>
<span className="text-sm font-medium text-neutral-900 dark:text-white truncate">
{row.event_name.replace(/_/g, ' ')}
</span>
<span className="text-sm font-semibold text-brand-orange tabular-nums">
{formatNumber(row.count)}
</span>
</div>
))
) : (
<div className="h-full flex flex-col items-center justify-center text-center px-4">
<p className="text-neutral-500 dark:text-neutral-400 text-sm">
No custom events in this period. Track goals 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>
)}
</div>
</div>
)
}

48
lib/api/goals.ts Normal file
View File

@@ -0,0 +1,48 @@
import apiRequest from './client'
export interface Goal {
id: string
site_id: string
name: string
event_name: string
funnel_steps: string[]
created_at: string
updated_at: string
}
export interface CreateGoalRequest {
name: string
event_name: string
funnel_steps?: string[]
}
export interface UpdateGoalRequest {
name: string
event_name: string
funnel_steps?: string[]
}
export async function listGoals(siteId: string): Promise<Goal[]> {
const res = await apiRequest<{ goals: Goal[] }>(`/sites/${siteId}/goals`)
return res?.goals ?? []
}
export async function createGoal(siteId: string, data: CreateGoalRequest): Promise<Goal> {
return apiRequest<Goal>(`/sites/${siteId}/goals`, {
method: 'POST',
body: JSON.stringify(data),
})
}
export async function updateGoal(siteId: string, goalId: string, data: UpdateGoalRequest): Promise<Goal> {
return apiRequest<Goal>(`/sites/${siteId}/goals/${goalId}`, {
method: 'PUT',
body: JSON.stringify(data),
})
}
export async function deleteGoal(siteId: string, goalId: string): Promise<void> {
await apiRequest(`/sites/${siteId}/goals/${goalId}`, {
method: 'DELETE',
})
}

View File

@@ -33,6 +33,11 @@ export interface PerformanceByPageStat {
inp: number | null
}
export interface GoalCountStat {
event_name: string
count: number
}
export interface TopReferrer {
referrer: string
pageviews: number
@@ -280,6 +285,7 @@ export interface DashboardData {
screen_resolutions: ScreenResolutionStat[]
performance?: PerformanceStats
performance_by_page?: PerformanceByPageStat[]
goal_counts?: GoalCountStat[]
}
export async function getDashboard(siteId: string, startDate?: string, endDate?: string, limit = 10, interval?: string): Promise<DashboardData> {

View File

@@ -228,4 +228,39 @@
// * Track popstate (browser back/forward)
window.addEventListener('popstate', trackPageview);
// * Custom events / goals: validate event name (letters, numbers, underscores only; max 64 chars)
var EVENT_NAME_MAX = 64;
var EVENT_NAME_REGEX = /^[a-zA-Z0-9_]+$/;
function trackCustomEvent(eventName) {
if (typeof eventName !== 'string' || !eventName.trim()) return;
var name = eventName.trim().toLowerCase();
if (name.length > EVENT_NAME_MAX || !EVENT_NAME_REGEX.test(name)) {
if (typeof console !== 'undefined' && console.warn) {
console.warn('Pulse: event name must contain only letters, numbers, and underscores (max ' + EVENT_NAME_MAX + ' chars).');
}
return;
}
var path = window.location.pathname + window.location.search;
var referrer = document.referrer || '';
var screenSize = { width: window.innerWidth || 0, height: window.innerHeight || 0 };
var payload = {
domain: domain,
path: path,
referrer: referrer,
screen: screenSize,
session_id: getSessionId(),
name: name,
};
fetch(apiUrl + '/api/v1/events', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
keepalive: true,
}).catch(function() {});
}
// * Expose pulse.track() for custom events (e.g. pulse.track('signup_click'))
window.pulse = { track: trackCustomEvent };
})();