[PULSE-4] Goals & Events dashboard block and settings UI #5
@@ -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('event_name')</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">'signup_click'</span>
|
||||
<span className="text-white">);</span>
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -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} />
|
||||
</div>
|
||||
|
||||
<DatePicker
|
||||
isOpen={isDatePickerOpen}
|
||||
onClose={() => setIsDatePickerOpen(false)}
|
||||
|
||||
@@ -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,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) => {
|
||||
e.preventDefault()
|
||||
setSaving(true)
|
||||
@@ -260,6 +348,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 +917,113 @@ 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('event_name')</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
|
||||
/>
|
||||
<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
|
||||
isOpen={showVerificationModal}
|
||||
onClose={() => setShowVerificationModal(false)}
|
||||
|
||||
64
components/dashboard/GoalStats.tsx
Normal file
64
components/dashboard/GoalStats.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
'use client'
|
||||
|
|
||||
|
||||
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('event_name')</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
48
lib/api/goals.ts
Normal 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',
|
||||
})
|
||||
}
|
||||
@@ -33,6 +33,12 @@ export interface PerformanceByPageStat {
|
||||
inp: number | null
|
||||
}
|
||||
|
||||
export interface GoalCountStat {
|
||||
event_name: string
|
||||
count: number
|
||||
display_name?: string | null
|
||||
}
|
||||
|
||||
export interface TopReferrer {
|
||||
referrer: string
|
||||
pageviews: number
|
||||
@@ -280,6 +286,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> {
|
||||
|
||||
@@ -228,4 +228,40 @@
|
||||
// * Track popstate (browser back/forward)
|
||||
|
overwrites existing Prompt To Fix With AIoverwrites 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);
|
||||
|
||||
// * 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;
|
||||
|
||||
})();
|
||||
|
||||
Reference in New Issue
Block a user
displays transformed
event_nameinstead of goal's display name. The API returnsGoalCountStatwith onlyevent_nameandcount, but goals have a separatenamefield (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
siteIdanddateRangeprops are unused - can remove from interface and componentPrompt To Fix With AI