[PULSE-4] Goals & Events dashboard block and settings UI #5

Merged
uz1mani merged 8 commits from staging into main 2026-02-04 15:04:10 +00:00
7 changed files with 392 additions and 2 deletions

View File

@@ -57,6 +57,33 @@ export default function InstallationPage() {
</div> </div>
</div> </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>
</div> </div>
) )

View File

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

View File

@@ -3,12 +3,13 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { useParams, useRouter } from 'next/navigation' import { useParams, useRouter } from 'next/navigation'
import { getSite, updateSite, resetSiteData, deleteSite, type Site, type GeoDataLevel } from '@/lib/api/sites' 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 { toast } from '@ciphera-net/ui'
import { getAuthErrorMessage } from '@/lib/utils/authErrors' import { getAuthErrorMessage } from '@/lib/utils/authErrors'
import { LoadingOverlay } from '@ciphera-net/ui' import { LoadingOverlay } from '@ciphera-net/ui'
import VerificationModal from '@/components/sites/VerificationModal' import VerificationModal from '@/components/sites/VerificationModal'
import { PasswordInput } from '@ciphera-net/ui' 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 { APP_URL, API_URL } from '@/lib/api/client'
import { generatePrivacySnippet } from '@/lib/utils/privacySnippet' import { generatePrivacySnippet } from '@/lib/utils/privacySnippet'
import { motion, AnimatePresence } from 'framer-motion' import { motion, AnimatePresence } from 'framer-motion'
@@ -49,7 +50,7 @@ export default function SiteSettingsPage() {
const [site, setSite] = useState<Site | null>(null) const [site, setSite] = useState<Site | null>(null)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false) 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({ const [formData, setFormData] = useState({
name: '', name: '',
@@ -73,11 +74,23 @@ export default function SiteSettingsPage() {
const [snippetCopied, setSnippetCopied] = useState(false) const [snippetCopied, setSnippetCopied] = useState(false)
const [showVerificationModal, setShowVerificationModal] = useState(false) const [showVerificationModal, setShowVerificationModal] = useState(false)
const [isPasswordEnabled, setIsPasswordEnabled] = 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(() => { useEffect(() => {
loadSite() loadSite()
}, [siteId]) }, [siteId])
useEffect(() => {
if (activeTab === 'goals' && siteId) {
loadGoals()
}
}, [activeTab, siteId])
const loadSite = async () => { const loadSite = async () => {
try { try {
setLoading(true) setLoading(true)
@@ -112,6 +125,81 @@ 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 (eventName.length > 64) {
toast.error('Event name must be 64 characters or less')
return
}
if (!/^[a-zA-Z0-9_]+$/.test(eventName)) {
toast.error('Event name can only contain letters, numbers, and underscores')
return
}
const duplicateEventName = editingGoal
? goals.some((g) => g.id !== editingGoal.id && g.event_name === eventName)
: goals.some((g) => g.event_name === eventName)
if (duplicateEventName) {
toast.error('A goal with this event name already exists')
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) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault() e.preventDefault()
setSaving(true) setSaving(true)
@@ -260,6 +348,17 @@ export default function SiteSettingsPage() {
<SettingsIcon className="w-5 h-5" /> <SettingsIcon className="w-5 h-5" />
Data & Privacy Data & Privacy
</button> </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> </nav>
{/* Content Area */} {/* Content Area */}
@@ -818,11 +917,113 @@ export default function SiteSettingsPage() {
</form> </form>
</div> </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> </motion.div>
</div> </div>
</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
/>
<p className="mt-1 text-xs text-neutral-500 dark:text-neutral-400">Only letters, numbers, and underscores; spaces become underscores. Invalid characters cannot be used. Max 64 characters after formatting.</p>
{editingGoal && goalForm.event_name.trim().toLowerCase().replace(/\s+/g, '_') !== editingGoal.event_name && (
<p className="mt-2 text-xs text-amber-600 dark:text-amber-400">Changing event name does not reassign events already tracked under the previous name.</p>
)}
</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 <VerificationModal
isOpen={showVerificationModal} isOpen={showVerificationModal}
onClose={() => setShowVerificationModal(false)} onClose={() => setShowVerificationModal(false)}

View File

@@ -0,0 +1,64 @@
'use client'
greptile-apps[bot] commented 2026-02-04 14:34:49 +00:00 (Migrated from github.com)
Review

displays transformed event_name instead of goal's display name. The API returns GoalCountStat with only event_name and count, but goals have a separate name field (display name). Users define goals with friendly names like "User Signups" for event "signup_click", but this shows "signup click" (transformed event name).

Verify if the backend should return goal names with the counts, or if showing transformed event names is intentional.

Prompt To Fix With AI
This is a comment left during a code review.
Path: components/dashboard/GoalStats.tsx
Line: 36:36

Comment:
displays transformed `event_name` instead of goal's display name. The API returns `GoalCountStat` with only `event_name` and `count`, but goals have a separate `name` field (display name). Users define goals with friendly names like "User Signups" for event "signup_click", but this shows "signup click" (transformed event name).

Verify if the backend should return goal names with the counts, or if showing transformed event names is intentional.

How can I resolve this? If you propose a fix, please make it concise.
displays transformed `event_name` instead of goal's display name. The API returns `GoalCountStat` with only `event_name` and `count`, but goals have a separate `name` field (display name). Users define goals with friendly names like "User Signups" for event "signup_click", but this shows "signup click" (transformed event name). Verify if the backend should return goal names with the counts, or if showing transformed event names is intentional. <details><summary>Prompt To Fix With AI</summary> `````markdown This is a comment left during a code review. Path: components/dashboard/GoalStats.tsx Line: 36:36 Comment: displays transformed `event_name` instead of goal's display name. The API returns `GoalCountStat` with only `event_name` and `count`, but goals have a separate `name` field (display name). Users define goals with friendly names like "User Signups" for event "signup_click", but this shows "signup click" (transformed event name). Verify if the backend should return goal names with the counts, or if showing transformed event names is intentional. How can I resolve this? If you propose a fix, please make it concise. ````` </details>
greptile-apps[bot] commented 2026-02-04 14:46:23 +00:00 (Migrated from github.com)
Review

siteId and dateRange props are unused - can remove from interface and component

Prompt To Fix With AI
This is a comment left during a code review.
Path: components/dashboard/GoalStats.tsx
Line: 10:11

Comment:
`siteId` and `dateRange` props are unused - can remove from interface and component

How can I resolve this? If you propose a fix, please make it concise.
`siteId` and `dateRange` props are unused - can remove from interface and component <details><summary>Prompt To Fix With AI</summary> `````markdown This is a comment left during a code review. Path: components/dashboard/GoalStats.tsx Line: 10:11 Comment: `siteId` and `dateRange` props are unused - can remove from interface and component How can I resolve this? If you propose a fix, please make it concise. ````` </details>
import Link from 'next/link'
import { formatNumber } from '@/lib/utils/format'
import { BookOpenIcon, ArrowRightIcon } from '@ciphera-net/ui'
import type { GoalCountStat } from '@/lib/api/stats'
interface GoalStatsProps {
goalCounts: GoalCountStat[]
}
const LIMIT = 10
export default function GoalStats({ goalCounts }: 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>
{hasData ? (
<div className="space-y-2 flex-1 min-h-[200px]">
{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.display_name ?? row.event_name.replace(/_/g, ' ')}
</span>
<span className="text-sm font-semibold text-brand-orange tabular-nums">
{formatNumber(row.count)}
</span>
</div>
))}
</div>
) : (
<div className="flex-1 min-h-[200px] flex flex-col items-center justify-center text-center px-6 py-8 gap-4">
<div className="rounded-full bg-neutral-100 dark:bg-neutral-800 p-4">
<BookOpenIcon className="w-8 h-8 text-neutral-500 dark:text-neutral-400" />
</div>
<h4 className="font-semibold text-neutral-900 dark:text-white">
Need help tracking goals?
</h4>
<p className="text-sm text-neutral-500 dark:text-neutral-400 max-w-md">
Add <code className="px-1.5 py-0.5 rounded bg-neutral-200 dark:bg-neutral-700 text-xs font-mono">pulse.track(&apos;event_name&apos;)</code> where actions happen on your site, then see counts here. Check our guide for step-by-step instructions.
</p>
<Link
href="/installation"
className="inline-flex items-center gap-1.5 text-sm font-medium text-brand-orange hover:text-brand-orange/90 hover:underline focus:outline-none focus:ring-2 focus:ring-brand-orange/20 rounded"
>
Read documentation
<ArrowRightIcon className="w-4 h-4" />
</Link>
</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,12 @@ export interface PerformanceByPageStat {
inp: number | null inp: number | null
} }
export interface GoalCountStat {
event_name: string
count: number
display_name?: string | null
}
export interface TopReferrer { export interface TopReferrer {
referrer: string referrer: string
pageviews: number pageviews: number
@@ -280,6 +286,7 @@ export interface DashboardData {
screen_resolutions: ScreenResolutionStat[] screen_resolutions: ScreenResolutionStat[]
performance?: PerformanceStats performance?: PerformanceStats
performance_by_page?: PerformanceByPageStat[] performance_by_page?: PerformanceByPageStat[]
goal_counts?: GoalCountStat[]
} }
export async function getDashboard(siteId: string, startDate?: string, endDate?: string, limit = 10, interval?: string): Promise<DashboardData> { export async function getDashboard(siteId: string, startDate?: string, endDate?: string, limit = 10, interval?: string): Promise<DashboardData> {

View File

@@ -228,4 +228,40 @@
// * Track popstate (browser back/forward) // * Track popstate (browser back/forward)
greptile-apps[bot] commented 2026-02-04 14:53:21 +00:00 (Migrated from github.com)
Review

overwrites existing window.pulse if it exists. If the script loads multiple times or another library uses window.pulse, this silently replaces it.

  window.pulse = window.pulse || {};
  window.pulse.track = trackCustomEvent;
Prompt To Fix With AI
This is a comment left during a code review.
Path: public/script.js
Line: 264:264

Comment:
overwrites existing `window.pulse` if it exists. If the script loads multiple times or another library uses `window.pulse`, this silently replaces it.

```suggestion
  window.pulse = window.pulse || {};
  window.pulse.track = trackCustomEvent;
```

How can I resolve this? If you propose a fix, please make it concise.
overwrites existing `window.pulse` if it exists. If the script loads multiple times or another library uses `window.pulse`, this silently replaces it. ```suggestion window.pulse = window.pulse || {}; window.pulse.track = trackCustomEvent; ``` <details><summary>Prompt To Fix With AI</summary> `````markdown This is a comment left during a code review. Path: public/script.js Line: 264:264 Comment: overwrites existing `window.pulse` if it exists. If the script loads multiple times or another library uses `window.pulse`, this silently replaces it. ```suggestion window.pulse = window.pulse || {}; window.pulse.track = trackCustomEvent; ``` How can I resolve this? If you propose a fix, please make it concise. ````` </details>
window.addEventListener('popstate', trackPageview); 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 = window.pulse || {};
window.pulse.track = trackCustomEvent;
})(); })();