Merge pull request #5 from ciphera-net/staging
[PULSE-4] Goals & Events dashboard block and settings UI
This commit is contained in:
@@ -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)
|
||||
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