From 90a743c170e37e15f13271ee8f6945cc2b7cee43 Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Wed, 4 Feb 2026 14:33:06 +0100 Subject: [PATCH] feat: implement custom events tracking and goals management in the dashboard --- app/installation/page.tsx | 27 ++++ app/sites/[id]/page.tsx | 7 ++ app/sites/[id]/settings/page.tsx | 190 ++++++++++++++++++++++++++++- components/dashboard/GoalStats.tsx | 51 ++++++++ lib/api/goals.ts | 48 ++++++++ lib/api/stats.ts | 6 + public/script.js | 35 ++++++ 7 files changed, 362 insertions(+), 2 deletions(-) create mode 100644 components/dashboard/GoalStats.tsx create mode 100644 lib/api/goals.ts diff --git a/app/installation/page.tsx b/app/installation/page.tsx index 0f669d2..e054635 100644 --- a/app/installation/page.tsx +++ b/app/installation/page.tsx @@ -57,6 +57,33 @@ export default function InstallationPage() { + +
+

Custom events (goals)

+

+ Track custom events (e.g. signup, purchase) with pulse.track('event_name'). Use letters, numbers, and underscores only. Define goals in your site Settings → Goals & Events to see counts in the dashboard. +

+
+
+
+
+
+
+
+ e.g. button click handler +
+
+ + pulse + . + track + ( + 'signup_click' + ); + +
+
+
) diff --git a/app/sites/[id]/page.tsx b/app/sites/[id]/page.tsx index da85fa1..9e646a3 100644 --- a/app/sites/[id]/page.tsx +++ b/app/sites/[id]/page.tsx @@ -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([]) const [performance, setPerformance] = useState<{ lcp: number, cls: number, inp: number }>({ lcp: 0, cls: 0, inp: 0 }) const [performanceByPage, setPerformanceByPage] = useState(null) + const [goalCounts, setGoalCounts] = useState>([]) 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() { />
+
+ +
+ setIsDatePickerOpen(false)} diff --git a/app/sites/[id]/settings/page.tsx b/app/sites/[id]/settings/page.tsx index 603bd57..74a89ac 100644 --- a/app/sites/[id]/settings/page.tsx +++ b/app/sites/[id]/settings/page.tsx @@ -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(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([]) + const [goalsLoading, setGoalsLoading] = useState(false) + const [goalModalOpen, setGoalModalOpen] = useState(false) + const [editingGoal, setEditingGoal] = useState(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() { Data & Privacy + {/* Content Area */} @@ -818,11 +906,109 @@ export default function SiteSettingsPage() { )} + + {activeTab === 'goals' && ( +
+
+

Goals & Events

+

+ Define goals to label custom events (e.g. signup, purchase). Track with pulse.track('event_name') in your snippet. +

+
+ {goalsLoading ? ( +
Loading goals…
+ ) : ( + <> + {canEdit && ( + + )} +
+ {goals.length === 0 ? ( +
+ No goals yet. Add a goal to give custom events a display name in the dashboard. +
+ ) : ( + goals.map((goal) => ( +
+
+ {goal.name} + ({goal.event_name}) +
+ {canEdit && ( +
+ + +
+ )} +
+ )) + )} +
+ + )} +
+ )} + setGoalModalOpen(false)} + title={editingGoal ? 'Edit goal' : 'Add goal'} + > +
+
+ + 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 + /> +
+
+ + 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 + /> +
+
+ + +
+
+
+ setShowVerificationModal(false)} diff --git a/components/dashboard/GoalStats.tsx b/components/dashboard/GoalStats.tsx new file mode 100644 index 0000000..13853e9 --- /dev/null +++ b/components/dashboard/GoalStats.tsx @@ -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 ( +
+
+

+ Goals & Events +

+
+ +
+ {hasData ? ( + list.map((row) => ( +
+ + {row.event_name.replace(/_/g, ' ')} + + + {formatNumber(row.count)} + +
+ )) + ) : ( +
+

+ No custom events in this period. Track goals with pulse.track('event_name') in your snippet. +

+
+ )} +
+
+ ) +} diff --git a/lib/api/goals.ts b/lib/api/goals.ts new file mode 100644 index 0000000..c594525 --- /dev/null +++ b/lib/api/goals.ts @@ -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 { + const res = await apiRequest<{ goals: Goal[] }>(`/sites/${siteId}/goals`) + return res?.goals ?? [] +} + +export async function createGoal(siteId: string, data: CreateGoalRequest): Promise { + return apiRequest(`/sites/${siteId}/goals`, { + method: 'POST', + body: JSON.stringify(data), + }) +} + +export async function updateGoal(siteId: string, goalId: string, data: UpdateGoalRequest): Promise { + return apiRequest(`/sites/${siteId}/goals/${goalId}`, { + method: 'PUT', + body: JSON.stringify(data), + }) +} + +export async function deleteGoal(siteId: string, goalId: string): Promise { + await apiRequest(`/sites/${siteId}/goals/${goalId}`, { + method: 'DELETE', + }) +} diff --git a/lib/api/stats.ts b/lib/api/stats.ts index fd698af..b266395 100644 --- a/lib/api/stats.ts +++ b/lib/api/stats.ts @@ -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 { diff --git a/public/script.js b/public/script.js index 2a08297..4861afb 100644 --- a/public/script.js +++ b/public/script.js @@ -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 }; + })();