feat: add dashboard dimension filtering and custom event properties
Dashboard filtering: FilterBar pills, AddFilterDropdown with dimension/ operator/value steps, URL-serialized filters, all SWR hooks filter-aware. Custom event properties: pulse.track() accepts props object, EventProperties panel with auto-discovered key tabs and value bar charts, clickable goal rows. Updated changelog with both features under v0.13.0-alpha.
This commit is contained in:
@@ -10,6 +10,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
|
||||
### Added
|
||||
|
||||
- **Dashboard filtering.** You can now filter your entire dashboard by any dimension — browser, country, page, device, OS, referrer, or UTM parameters. Click "Add filter" to pick a dimension, choose an operator (is, is not, contains, does not contain), and enter a value. Active filters appear as removable pills above your charts. You can also stack multiple filters to narrow down exactly the traffic you're looking at. Filter selections are saved in the URL, so you can bookmark or share a filtered view with your team.
|
||||
- **Custom event properties.** Your custom events can now carry extra context. For example, tracking a signup with `pulse.track('signup', { plan: 'pro', source: 'landing' })` records the plan and source alongside the event. Click any event in your Goals & Events panel to see a breakdown of its properties and values — no setup or registration needed. This helps you understand not just what happened, but why.
|
||||
- **AI traffic source identification.** Pulse now automatically recognizes visitors coming from AI tools — ChatGPT, Perplexity, Claude, Gemini, Copilot, DeepSeek, Grok, Meta AI, You.com, and Phind. These sources appear in your Top Referrers with proper brand icons and display names instead of raw domain URLs. If someone clicks a link in an AI chat to visit your site, you'll see exactly which AI tool sent them.
|
||||
- **Automatic outbound link tracking.** Pulse now tracks when visitors click links that take them to other websites. These show up as "outbound link" events in your Goals & Events panel — no setup needed. You can turn this off in your tracking snippet settings if you prefer.
|
||||
- **Automatic file download tracking.** When a visitor clicks a link to a downloadable file — PDF, ZIP, Excel, Word, MP3, and 20+ other formats — Pulse records it as a "file download" event. Like outbound links, this works automatically with no setup required.
|
||||
@@ -294,7 +296,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
---
|
||||
|
||||
[Unreleased]: https://github.com/ciphera-net/pulse/compare/v0.13.0-alpha...HEAD
|
||||
[0.13.0-alpha]: https://github.com/ciphera-net/pulse/compare/v0.12.0-alpha...v0.13.0-alpha
|
||||
[0.13.0-alpha]: https://github.com/ciphera-net/pulse/releases/tag/v0.13.0-alpha
|
||||
[0.12.0-alpha]: https://github.com/ciphera-net/pulse/compare/v0.11.1-alpha...v0.12.0-alpha
|
||||
[0.11.1-alpha]: https://github.com/ciphera-net/pulse/compare/v0.11.0-alpha...v0.11.1-alpha
|
||||
[0.11.0-alpha]: https://github.com/ciphera-net/pulse/compare/v0.10.0-alpha...v0.11.0-alpha
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
import { useAuth } from '@/lib/auth/context'
|
||||
import { logger } from '@/lib/utils/logger'
|
||||
import { useEffect, useState, useMemo } from 'react'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import { useCallback, useEffect, useState, useMemo } from 'react'
|
||||
import { useParams, useRouter, useSearchParams } from 'next/navigation'
|
||||
import { motion } from 'framer-motion'
|
||||
import { getPerformanceByPage, type Stats, type DailyStat } from '@/lib/api/stats'
|
||||
import { getDateRange } from '@ciphera-net/ui'
|
||||
@@ -21,6 +21,10 @@ import PerformanceStats from '@/components/dashboard/PerformanceStats'
|
||||
import GoalStats from '@/components/dashboard/GoalStats'
|
||||
import ScrollDepth from '@/components/dashboard/ScrollDepth'
|
||||
import Campaigns from '@/components/dashboard/Campaigns'
|
||||
import FilterBar from '@/components/dashboard/FilterBar'
|
||||
import AddFilterDropdown from '@/components/dashboard/AddFilterDropdown'
|
||||
import EventProperties from '@/components/dashboard/EventProperties'
|
||||
import { type DimensionFilter, serializeFilters, parseFiltersFromURL } from '@/lib/filters'
|
||||
import {
|
||||
useDashboardOverview,
|
||||
useDashboardPages,
|
||||
@@ -82,6 +86,40 @@ export default function SiteDashboardPage() {
|
||||
const [lastUpdatedAt, setLastUpdatedAt] = useState<number | null>(null)
|
||||
const [, setTick] = useState(0)
|
||||
|
||||
// Dimension filters state
|
||||
const searchParams = useSearchParams()
|
||||
const [filters, setFilters] = useState<DimensionFilter[]>(() => {
|
||||
const raw = searchParams.get('filters')
|
||||
return raw ? parseFiltersFromURL(raw) : []
|
||||
})
|
||||
const filtersParam = useMemo(() => serializeFilters(filters), [filters])
|
||||
|
||||
// Selected event for property breakdown
|
||||
const [selectedEvent, setSelectedEvent] = useState<string | null>(null)
|
||||
|
||||
const handleAddFilter = useCallback((filter: DimensionFilter) => {
|
||||
setFilters(prev => [...prev, filter])
|
||||
}, [])
|
||||
|
||||
const handleRemoveFilter = useCallback((index: number) => {
|
||||
setFilters(prev => prev.filter((_, i) => i !== index))
|
||||
}, [])
|
||||
|
||||
const handleClearFilters = useCallback(() => {
|
||||
setFilters([])
|
||||
}, [])
|
||||
|
||||
// Sync filters to URL
|
||||
useEffect(() => {
|
||||
const url = new URL(window.location.href)
|
||||
if (filtersParam) {
|
||||
url.searchParams.set('filters', filtersParam)
|
||||
} else {
|
||||
url.searchParams.delete('filters')
|
||||
}
|
||||
window.history.replaceState({}, '', url.toString())
|
||||
}, [filtersParam])
|
||||
|
||||
const interval = dateRange.start === dateRange.end ? todayInterval : multiDayInterval
|
||||
|
||||
// Previous period date range for comparison
|
||||
@@ -100,13 +138,14 @@ export default function SiteDashboardPage() {
|
||||
|
||||
// SWR hooks - replace manual useState + useEffect + setInterval polling
|
||||
// Each hook handles its own refresh interval, deduplication, and error retry
|
||||
const { data: overview, isLoading: overviewLoading, error: overviewError } = useDashboardOverview(siteId, dateRange.start, dateRange.end, interval)
|
||||
const { data: pages } = useDashboardPages(siteId, dateRange.start, dateRange.end)
|
||||
const { data: locations } = useDashboardLocations(siteId, dateRange.start, dateRange.end)
|
||||
const { data: devicesData } = useDashboardDevices(siteId, dateRange.start, dateRange.end)
|
||||
const { data: referrers } = useDashboardReferrers(siteId, dateRange.start, dateRange.end)
|
||||
const { data: performanceData } = useDashboardPerformance(siteId, dateRange.start, dateRange.end)
|
||||
const { data: goalsData } = useDashboardGoals(siteId, dateRange.start, dateRange.end)
|
||||
// Filters are included in cache keys so changing filters auto-refetches
|
||||
const { data: overview, isLoading: overviewLoading, error: overviewError } = useDashboardOverview(siteId, dateRange.start, dateRange.end, interval, filtersParam || undefined)
|
||||
const { data: pages } = useDashboardPages(siteId, dateRange.start, dateRange.end, filtersParam || undefined)
|
||||
const { data: locations } = useDashboardLocations(siteId, dateRange.start, dateRange.end, filtersParam || undefined)
|
||||
const { data: devicesData } = useDashboardDevices(siteId, dateRange.start, dateRange.end, filtersParam || undefined)
|
||||
const { data: referrers } = useDashboardReferrers(siteId, dateRange.start, dateRange.end, filtersParam || undefined)
|
||||
const { data: performanceData } = useDashboardPerformance(siteId, dateRange.start, dateRange.end, filtersParam || undefined)
|
||||
const { data: goalsData } = useDashboardGoals(siteId, dateRange.start, dateRange.end, filtersParam || undefined)
|
||||
const { data: realtimeData } = useRealtime(siteId)
|
||||
const { data: prevStats } = useStats(siteId, prevRange.start, prevRange.end)
|
||||
const { data: prevDailyStats } = useDailyStats(siteId, prevRange.start, prevRange.end, interval)
|
||||
@@ -306,6 +345,12 @@ export default function SiteDashboardPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Dimension Filters */}
|
||||
<div className="flex items-center gap-2 flex-wrap mb-2">
|
||||
<FilterBar filters={filters} onRemove={handleRemoveFilter} onClear={handleClearFilters} />
|
||||
<AddFilterDropdown onAdd={handleAddFilter} />
|
||||
</div>
|
||||
|
||||
{/* Advanced Chart with Integrated Stats */}
|
||||
<div className="mb-8">
|
||||
<Chart
|
||||
@@ -382,10 +427,25 @@ export default function SiteDashboardPage() {
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-2 mb-8">
|
||||
<GoalStats goalCounts={(goalsData?.goal_counts ?? []).filter(g => !/^scroll_\d+$/.test(g.event_name))} />
|
||||
<GoalStats
|
||||
goalCounts={(goalsData?.goal_counts ?? []).filter(g => !/^scroll_\d+$/.test(g.event_name))}
|
||||
onSelectEvent={setSelectedEvent}
|
||||
/>
|
||||
<ScrollDepth goalCounts={goalsData?.goal_counts ?? []} totalPageviews={stats.pageviews} />
|
||||
</div>
|
||||
|
||||
{/* Event Properties Breakdown */}
|
||||
{selectedEvent && (
|
||||
<div className="mb-8">
|
||||
<EventProperties
|
||||
siteId={siteId}
|
||||
eventName={selectedEvent}
|
||||
dateRange={dateRange}
|
||||
onClose={() => setSelectedEvent(null)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DatePicker
|
||||
isOpen={isDatePickerOpen}
|
||||
onClose={() => setIsDatePickerOpen(false)}
|
||||
|
||||
119
components/dashboard/AddFilterDropdown.tsx
Normal file
119
components/dashboard/AddFilterDropdown.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { DIMENSIONS, DIMENSION_LABELS, OPERATORS, OPERATOR_LABELS, type DimensionFilter } from '@/lib/filters'
|
||||
|
||||
interface AddFilterDropdownProps {
|
||||
onAdd: (filter: DimensionFilter) => void
|
||||
}
|
||||
|
||||
type Step = 'dimension' | 'operator' | 'value'
|
||||
|
||||
export default function AddFilterDropdown({ onAdd }: AddFilterDropdownProps) {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [step, setStep] = useState<Step>('dimension')
|
||||
const [dimension, setDimension] = useState('')
|
||||
const [operator, setOperator] = useState<DimensionFilter['operator']>('is')
|
||||
const [value, setValue] = useState('')
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
function handleClickOutside(e: MouseEvent) {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) {
|
||||
setIsOpen(false)
|
||||
resetState()
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||
}, [])
|
||||
|
||||
function resetState() {
|
||||
setStep('dimension')
|
||||
setDimension('')
|
||||
setOperator('is')
|
||||
setValue('')
|
||||
}
|
||||
|
||||
function handleSubmit() {
|
||||
if (!dimension || !operator || !value.trim()) return
|
||||
onAdd({ dimension, operator, values: [value.trim()] })
|
||||
setIsOpen(false)
|
||||
resetState()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative" ref={ref}>
|
||||
<button
|
||||
onClick={() => { setIsOpen(!isOpen); if (!isOpen) resetState() }}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-lg border border-dashed border-neutral-300 dark:border-neutral-700 text-neutral-600 dark:text-neutral-400 hover:border-neutral-400 dark:hover:border-neutral-600 hover:text-neutral-700 dark:hover:text-neutral-300 transition-colors cursor-pointer"
|
||||
>
|
||||
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
Add filter
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div className="absolute top-full left-0 mt-1 z-50 w-56 bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-xl shadow-lg overflow-hidden">
|
||||
{step === 'dimension' && (
|
||||
<div className="p-1">
|
||||
<div className="px-3 py-2 text-[10px] font-semibold uppercase tracking-wider text-neutral-400">
|
||||
Select dimension
|
||||
</div>
|
||||
{DIMENSIONS.map(dim => (
|
||||
<button
|
||||
key={dim}
|
||||
onClick={() => { setDimension(dim); setStep('operator') }}
|
||||
className="w-full text-left px-3 py-2 text-sm text-neutral-700 dark:text-neutral-300 hover:bg-neutral-100 dark:hover:bg-neutral-800 rounded-lg transition-colors cursor-pointer"
|
||||
>
|
||||
{DIMENSION_LABELS[dim]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 'operator' && (
|
||||
<div className="p-1">
|
||||
<div className="px-3 py-2 text-[10px] font-semibold uppercase tracking-wider text-neutral-400">
|
||||
{DIMENSION_LABELS[dimension]} ...
|
||||
</div>
|
||||
{OPERATORS.map(op => (
|
||||
<button
|
||||
key={op}
|
||||
onClick={() => { setOperator(op); setStep('value') }}
|
||||
className="w-full text-left px-3 py-2 text-sm text-neutral-700 dark:text-neutral-300 hover:bg-neutral-100 dark:hover:bg-neutral-800 rounded-lg transition-colors cursor-pointer"
|
||||
>
|
||||
{OPERATOR_LABELS[op]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 'value' && (
|
||||
<div className="p-3">
|
||||
<div className="text-[10px] font-semibold uppercase tracking-wider text-neutral-400 mb-2">
|
||||
{DIMENSION_LABELS[dimension]} {OPERATOR_LABELS[operator]}
|
||||
</div>
|
||||
<input
|
||||
autoFocus
|
||||
value={value}
|
||||
onChange={e => setValue(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') handleSubmit() }}
|
||||
placeholder="Enter value..."
|
||||
className="w-full px-3 py-2 text-sm bg-neutral-50 dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 rounded-lg text-neutral-900 dark:text-white placeholder-neutral-400 focus:outline-none focus:ring-2 focus:ring-brand-orange/30"
|
||||
/>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={!value.trim()}
|
||||
className="w-full mt-2 px-3 py-2 text-sm font-medium bg-brand-orange text-white rounded-lg hover:bg-brand-orange/90 disabled:opacity-40 disabled:cursor-not-allowed transition-colors cursor-pointer"
|
||||
>
|
||||
Apply filter
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
108
components/dashboard/EventProperties.tsx
Normal file
108
components/dashboard/EventProperties.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { formatNumber } from '@ciphera-net/ui'
|
||||
import { getEventPropertyKeys, getEventPropertyValues, type EventPropertyKey, type EventPropertyValue } from '@/lib/api/stats'
|
||||
|
||||
interface EventPropertiesProps {
|
||||
siteId: string
|
||||
eventName: string
|
||||
dateRange: { start: string; end: string }
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export default function EventProperties({ siteId, eventName, dateRange, onClose }: EventPropertiesProps) {
|
||||
const [keys, setKeys] = useState<EventPropertyKey[]>([])
|
||||
const [selectedKey, setSelectedKey] = useState<string | null>(null)
|
||||
const [values, setValues] = useState<EventPropertyValue[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true)
|
||||
getEventPropertyKeys(siteId, eventName, dateRange.start, dateRange.end)
|
||||
.then(k => {
|
||||
setKeys(k)
|
||||
if (k.length > 0) setSelectedKey(k[0].key)
|
||||
})
|
||||
.finally(() => setLoading(false))
|
||||
}, [siteId, eventName, dateRange.start, dateRange.end])
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedKey) return
|
||||
getEventPropertyValues(siteId, eventName, selectedKey, dateRange.start, dateRange.end)
|
||||
.then(setValues)
|
||||
}, [siteId, eventName, selectedKey, dateRange.start, dateRange.end])
|
||||
|
||||
const maxCount = values.length > 0 ? values[0].count : 1
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white">
|
||||
Properties: <span className="text-brand-orange">{eventName.replace(/_/g, ' ')}</span>
|
||||
</h3>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-neutral-400 hover:text-neutral-600 dark:hover:text-neutral-300 transition-colors cursor-pointer"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="animate-pulse space-y-3">
|
||||
{[1, 2, 3].map(i => (
|
||||
<div key={i} className="h-8 bg-neutral-100 dark:bg-neutral-800 rounded-lg" />
|
||||
))}
|
||||
</div>
|
||||
) : keys.length === 0 ? (
|
||||
<p className="text-sm text-neutral-500 dark:text-neutral-400 py-4 text-center">
|
||||
No properties recorded for this event yet.
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex gap-2 mb-4 flex-wrap">
|
||||
{keys.map(k => (
|
||||
<button
|
||||
key={k.key}
|
||||
onClick={() => setSelectedKey(k.key)}
|
||||
className={`px-3 py-1 text-xs font-medium rounded-full transition-colors cursor-pointer ${
|
||||
selectedKey === k.key
|
||||
? 'bg-brand-orange text-white'
|
||||
: 'bg-neutral-100 dark:bg-neutral-800 text-neutral-600 dark:text-neutral-400 hover:bg-neutral-200 dark:hover:bg-neutral-700'
|
||||
}`}
|
||||
>
|
||||
{k.key}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{values.map(v => (
|
||||
<div key={v.value} className="flex items-center gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="text-sm font-medium text-neutral-900 dark:text-white truncate">
|
||||
{v.value}
|
||||
</span>
|
||||
<span className="text-xs font-semibold text-brand-orange tabular-nums ml-2">
|
||||
{formatNumber(v.count)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full h-1.5 bg-neutral-100 dark:bg-neutral-800 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-brand-orange/60 rounded-full transition-all"
|
||||
style={{ width: `${(v.count / maxCount) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
42
components/dashboard/FilterBar.tsx
Normal file
42
components/dashboard/FilterBar.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
'use client'
|
||||
|
||||
import { type DimensionFilter, filterLabel } from '@/lib/filters'
|
||||
|
||||
interface FilterBarProps {
|
||||
filters: DimensionFilter[]
|
||||
onRemove: (index: number) => void
|
||||
onClear: () => void
|
||||
}
|
||||
|
||||
export default function FilterBar({ filters, onRemove, onClear }: FilterBarProps) {
|
||||
if (filters.length === 0) return null
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2 mb-4">
|
||||
<span className="text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider">
|
||||
Filters
|
||||
</span>
|
||||
{filters.map((f, i) => (
|
||||
<button
|
||||
key={`${f.dimension}-${f.operator}-${f.values.join(',')}`}
|
||||
onClick={() => onRemove(i)}
|
||||
className="inline-flex items-center gap-1.5 px-2.5 py-1 text-xs font-medium rounded-full bg-brand-orange/10 text-brand-orange border border-brand-orange/20 hover:bg-brand-orange/20 transition-colors cursor-pointer group"
|
||||
title={`Remove filter: ${filterLabel(f)}`}
|
||||
>
|
||||
<span>{filterLabel(f)}</span>
|
||||
<svg className="w-3 h-3 opacity-60 group-hover:opacity-100" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
))}
|
||||
{filters.length > 1 && (
|
||||
<button
|
||||
onClick={onClear}
|
||||
className="text-xs text-neutral-500 dark:text-neutral-400 hover:text-neutral-700 dark:hover:text-neutral-200 transition-colors cursor-pointer"
|
||||
>
|
||||
Clear all
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -7,11 +7,12 @@ import type { GoalCountStat } from '@/lib/api/stats'
|
||||
|
||||
interface GoalStatsProps {
|
||||
goalCounts: GoalCountStat[]
|
||||
onSelectEvent?: (eventName: string) => void
|
||||
}
|
||||
|
||||
const LIMIT = 10
|
||||
|
||||
export default function GoalStats({ goalCounts }: GoalStatsProps) {
|
||||
export default function GoalStats({ goalCounts, onSelectEvent }: GoalStatsProps) {
|
||||
const list = (goalCounts || []).slice(0, LIMIT)
|
||||
const hasData = list.length > 0
|
||||
|
||||
@@ -28,7 +29,8 @@ export default function GoalStats({ goalCounts }: GoalStatsProps) {
|
||||
{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"
|
||||
onClick={() => onSelectEvent?.(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${onSelectEvent ? ' cursor-pointer' : ''}`}
|
||||
>
|
||||
<span className="text-sm font-medium text-neutral-900 dark:text-white truncate">
|
||||
{row.display_name ?? row.event_name.replace(/_/g, ' ')}
|
||||
|
||||
@@ -120,6 +120,7 @@ function buildQuery(
|
||||
interval?: string
|
||||
countryLimit?: number
|
||||
sort?: string
|
||||
filters?: string
|
||||
},
|
||||
auth?: AuthParams
|
||||
): string {
|
||||
@@ -130,6 +131,7 @@ function buildQuery(
|
||||
if (opts.interval) params.append('interval', opts.interval)
|
||||
if (opts.countryLimit != null) params.append('country_limit', opts.countryLimit.toString())
|
||||
if (opts.sort) params.append('sort', opts.sort)
|
||||
if (opts.filters) params.append('filters', opts.filters)
|
||||
if (auth) appendAuthParams(params, auth)
|
||||
const query = params.toString()
|
||||
return query ? `?${query}` : ''
|
||||
@@ -137,8 +139,8 @@ function buildQuery(
|
||||
|
||||
/** Factory for endpoints that return an array nested under a response key. */
|
||||
function createListFetcher<T>(path: string, field: string, defaultLimit = 10) {
|
||||
return (siteId: string, startDate?: string, endDate?: string, limit = defaultLimit): Promise<T[]> =>
|
||||
apiRequest<Record<string, T[]>>(`/sites/${siteId}/${path}${buildQuery({ startDate, endDate, limit })}`)
|
||||
return (siteId: string, startDate?: string, endDate?: string, limit = defaultLimit, filters?: string): Promise<T[]> =>
|
||||
apiRequest<Record<string, T[]>>(`/sites/${siteId}/${path}${buildQuery({ startDate, endDate, limit, filters })}`)
|
||||
.then(r => r?.[field] || [])
|
||||
}
|
||||
|
||||
@@ -160,8 +162,8 @@ export const getCampaigns = createListFetcher<CampaignStat>('campaigns', 'campai
|
||||
|
||||
// ─── Stats & Realtime ───────────────────────────────────────────────
|
||||
|
||||
export function getStats(siteId: string, startDate?: string, endDate?: string): Promise<Stats> {
|
||||
return apiRequest<Stats>(`/sites/${siteId}/stats${buildQuery({ startDate, endDate })}`)
|
||||
export function getStats(siteId: string, startDate?: string, endDate?: string, filters?: string): Promise<Stats> {
|
||||
return apiRequest<Stats>(`/sites/${siteId}/stats${buildQuery({ startDate, endDate, filters })}`)
|
||||
}
|
||||
|
||||
export function getPublicStats(siteId: string, startDate?: string, endDate?: string, auth?: AuthParams): Promise<Stats> {
|
||||
@@ -178,8 +180,8 @@ export function getPublicRealtime(siteId: string, auth?: AuthParams): Promise<Re
|
||||
|
||||
// ─── Daily Stats ────────────────────────────────────────────────────
|
||||
|
||||
export function getDailyStats(siteId: string, startDate?: string, endDate?: string, interval?: string): Promise<DailyStat[]> {
|
||||
return apiRequest<{ stats: DailyStat[] }>(`/sites/${siteId}/daily${buildQuery({ startDate, endDate, interval })}`)
|
||||
export function getDailyStats(siteId: string, startDate?: string, endDate?: string, interval?: string, filters?: string): Promise<DailyStat[]> {
|
||||
return apiRequest<{ stats: DailyStat[] }>(`/sites/${siteId}/daily${buildQuery({ startDate, endDate, interval, filters })}`)
|
||||
.then(r => r?.stats || [])
|
||||
}
|
||||
|
||||
@@ -302,8 +304,8 @@ export interface DashboardGoalsData {
|
||||
goal_counts: GoalCountStat[]
|
||||
}
|
||||
|
||||
export function getDashboardOverview(siteId: string, startDate?: string, endDate?: string, interval?: string): Promise<DashboardOverviewData> {
|
||||
return apiRequest<DashboardOverviewData>(`/sites/${siteId}/dashboard/overview${buildQuery({ startDate, endDate, interval })}`)
|
||||
export function getDashboardOverview(siteId: string, startDate?: string, endDate?: string, interval?: string, filters?: string): Promise<DashboardOverviewData> {
|
||||
return apiRequest<DashboardOverviewData>(`/sites/${siteId}/dashboard/overview${buildQuery({ startDate, endDate, interval, filters })}`)
|
||||
}
|
||||
|
||||
export function getPublicDashboardOverview(
|
||||
@@ -313,8 +315,8 @@ export function getPublicDashboardOverview(
|
||||
return apiRequest<DashboardOverviewData>(`/public/sites/${siteId}/dashboard/overview${buildQuery({ startDate, endDate, interval }, { password, captcha })}`)
|
||||
}
|
||||
|
||||
export function getDashboardPages(siteId: string, startDate?: string, endDate?: string, limit = 10): Promise<DashboardPagesData> {
|
||||
return apiRequest<DashboardPagesData>(`/sites/${siteId}/dashboard/pages${buildQuery({ startDate, endDate, limit })}`)
|
||||
export function getDashboardPages(siteId: string, startDate?: string, endDate?: string, limit = 10, filters?: string): Promise<DashboardPagesData> {
|
||||
return apiRequest<DashboardPagesData>(`/sites/${siteId}/dashboard/pages${buildQuery({ startDate, endDate, limit, filters })}`)
|
||||
}
|
||||
|
||||
export function getPublicDashboardPages(
|
||||
@@ -324,8 +326,8 @@ export function getPublicDashboardPages(
|
||||
return apiRequest<DashboardPagesData>(`/public/sites/${siteId}/dashboard/pages${buildQuery({ startDate, endDate, limit }, { password, captcha })}`)
|
||||
}
|
||||
|
||||
export function getDashboardLocations(siteId: string, startDate?: string, endDate?: string, limit = 10, countryLimit = 250): Promise<DashboardLocationsData> {
|
||||
return apiRequest<DashboardLocationsData>(`/sites/${siteId}/dashboard/locations${buildQuery({ startDate, endDate, limit, countryLimit })}`)
|
||||
export function getDashboardLocations(siteId: string, startDate?: string, endDate?: string, limit = 10, countryLimit = 250, filters?: string): Promise<DashboardLocationsData> {
|
||||
return apiRequest<DashboardLocationsData>(`/sites/${siteId}/dashboard/locations${buildQuery({ startDate, endDate, limit, countryLimit, filters })}`)
|
||||
}
|
||||
|
||||
export function getPublicDashboardLocations(
|
||||
@@ -335,8 +337,8 @@ export function getPublicDashboardLocations(
|
||||
return apiRequest<DashboardLocationsData>(`/public/sites/${siteId}/dashboard/locations${buildQuery({ startDate, endDate, limit, countryLimit }, { password, captcha })}`)
|
||||
}
|
||||
|
||||
export function getDashboardDevices(siteId: string, startDate?: string, endDate?: string, limit = 10): Promise<DashboardDevicesData> {
|
||||
return apiRequest<DashboardDevicesData>(`/sites/${siteId}/dashboard/devices${buildQuery({ startDate, endDate, limit })}`)
|
||||
export function getDashboardDevices(siteId: string, startDate?: string, endDate?: string, limit = 10, filters?: string): Promise<DashboardDevicesData> {
|
||||
return apiRequest<DashboardDevicesData>(`/sites/${siteId}/dashboard/devices${buildQuery({ startDate, endDate, limit, filters })}`)
|
||||
}
|
||||
|
||||
export function getPublicDashboardDevices(
|
||||
@@ -346,8 +348,8 @@ export function getPublicDashboardDevices(
|
||||
return apiRequest<DashboardDevicesData>(`/public/sites/${siteId}/dashboard/devices${buildQuery({ startDate, endDate, limit }, { password, captcha })}`)
|
||||
}
|
||||
|
||||
export function getDashboardReferrers(siteId: string, startDate?: string, endDate?: string, limit = 10): Promise<DashboardReferrersData> {
|
||||
return apiRequest<DashboardReferrersData>(`/sites/${siteId}/dashboard/referrers${buildQuery({ startDate, endDate, limit })}`)
|
||||
export function getDashboardReferrers(siteId: string, startDate?: string, endDate?: string, limit = 10, filters?: string): Promise<DashboardReferrersData> {
|
||||
return apiRequest<DashboardReferrersData>(`/sites/${siteId}/dashboard/referrers${buildQuery({ startDate, endDate, limit, filters })}`)
|
||||
}
|
||||
|
||||
export function getPublicDashboardReferrers(
|
||||
@@ -357,8 +359,8 @@ export function getPublicDashboardReferrers(
|
||||
return apiRequest<DashboardReferrersData>(`/public/sites/${siteId}/dashboard/referrers${buildQuery({ startDate, endDate, limit }, { password, captcha })}`)
|
||||
}
|
||||
|
||||
export function getDashboardPerformance(siteId: string, startDate?: string, endDate?: string): Promise<DashboardPerformanceData> {
|
||||
return apiRequest<DashboardPerformanceData>(`/sites/${siteId}/dashboard/performance${buildQuery({ startDate, endDate })}`)
|
||||
export function getDashboardPerformance(siteId: string, startDate?: string, endDate?: string, filters?: string): Promise<DashboardPerformanceData> {
|
||||
return apiRequest<DashboardPerformanceData>(`/sites/${siteId}/dashboard/performance${buildQuery({ startDate, endDate, filters })}`)
|
||||
}
|
||||
|
||||
export function getPublicDashboardPerformance(
|
||||
@@ -368,8 +370,8 @@ export function getPublicDashboardPerformance(
|
||||
return apiRequest<DashboardPerformanceData>(`/public/sites/${siteId}/dashboard/performance${buildQuery({ startDate, endDate }, { password, captcha })}`)
|
||||
}
|
||||
|
||||
export function getDashboardGoals(siteId: string, startDate?: string, endDate?: string, limit = 10): Promise<DashboardGoalsData> {
|
||||
return apiRequest<DashboardGoalsData>(`/sites/${siteId}/dashboard/goals${buildQuery({ startDate, endDate, limit })}`)
|
||||
export function getDashboardGoals(siteId: string, startDate?: string, endDate?: string, limit = 10, filters?: string): Promise<DashboardGoalsData> {
|
||||
return apiRequest<DashboardGoalsData>(`/sites/${siteId}/dashboard/goals${buildQuery({ startDate, endDate, limit, filters })}`)
|
||||
}
|
||||
|
||||
export function getPublicDashboardGoals(
|
||||
@@ -378,3 +380,25 @@ export function getPublicDashboardGoals(
|
||||
): Promise<DashboardGoalsData> {
|
||||
return apiRequest<DashboardGoalsData>(`/public/sites/${siteId}/dashboard/goals${buildQuery({ startDate, endDate, limit }, { password, captcha })}`)
|
||||
}
|
||||
|
||||
// ─── Event Properties ────────────────────────────────────────────────
|
||||
|
||||
export interface EventPropertyKey {
|
||||
key: string
|
||||
count: number
|
||||
}
|
||||
|
||||
export interface EventPropertyValue {
|
||||
value: string
|
||||
count: number
|
||||
}
|
||||
|
||||
export function getEventPropertyKeys(siteId: string, eventName: string, startDate?: string, endDate?: string): Promise<EventPropertyKey[]> {
|
||||
return apiRequest<{ keys: EventPropertyKey[] }>(`/sites/${siteId}/goals/${encodeURIComponent(eventName)}/properties${buildQuery({ startDate, endDate })}`)
|
||||
.then(r => r?.keys || [])
|
||||
}
|
||||
|
||||
export function getEventPropertyValues(siteId: string, eventName: string, propName: string, startDate?: string, endDate?: string, limit = 20): Promise<EventPropertyValue[]> {
|
||||
return apiRequest<{ values: EventPropertyValue[] }>(`/sites/${siteId}/goals/${encodeURIComponent(eventName)}/properties/${encodeURIComponent(propName)}${buildQuery({ startDate, endDate, limit })}`)
|
||||
.then(r => r?.values || [])
|
||||
}
|
||||
|
||||
60
lib/filters.ts
Normal file
60
lib/filters.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
// * Dimension filter types and utilities for dashboard filtering
|
||||
|
||||
export interface DimensionFilter {
|
||||
dimension: string
|
||||
operator: 'is' | 'is_not' | 'contains' | 'not_contains'
|
||||
values: string[]
|
||||
}
|
||||
|
||||
export const DIMENSION_LABELS: Record<string, string> = {
|
||||
page: 'Page',
|
||||
referrer: 'Referrer',
|
||||
country: 'Country',
|
||||
city: 'City',
|
||||
region: 'Region',
|
||||
browser: 'Browser',
|
||||
os: 'OS',
|
||||
device: 'Device',
|
||||
utm_source: 'UTM Source',
|
||||
utm_medium: 'UTM Medium',
|
||||
utm_campaign: 'UTM Campaign',
|
||||
}
|
||||
|
||||
export const OPERATOR_LABELS: Record<string, string> = {
|
||||
is: 'is',
|
||||
is_not: 'is not',
|
||||
contains: 'contains',
|
||||
not_contains: 'does not contain',
|
||||
}
|
||||
|
||||
export const DIMENSIONS = Object.keys(DIMENSION_LABELS)
|
||||
export const OPERATORS = Object.keys(OPERATOR_LABELS) as DimensionFilter['operator'][]
|
||||
|
||||
/** Serialize filters to query param format: "browser|is|Chrome,country|is|US" */
|
||||
export function serializeFilters(filters: DimensionFilter[]): string {
|
||||
if (!filters.length) return ''
|
||||
return filters
|
||||
.map(f => `${f.dimension}|${f.operator}|${f.values.join(';')}`)
|
||||
.join(',')
|
||||
}
|
||||
|
||||
/** Parse filters from URL search param string */
|
||||
export function parseFiltersFromURL(raw: string): DimensionFilter[] {
|
||||
if (!raw) return []
|
||||
return raw.split(',').map(part => {
|
||||
const [dimension, operator, valuesRaw] = part.split('|')
|
||||
return {
|
||||
dimension,
|
||||
operator: operator as DimensionFilter['operator'],
|
||||
values: valuesRaw?.split(';') ?? [],
|
||||
}
|
||||
}).filter(f => f.dimension && f.operator && f.values.length > 0)
|
||||
}
|
||||
|
||||
/** Build display label for a filter pill */
|
||||
export function filterLabel(f: DimensionFilter): string {
|
||||
const dim = DIMENSION_LABELS[f.dimension] || f.dimension
|
||||
const op = OPERATOR_LABELS[f.operator] || f.operator
|
||||
const val = f.values.length > 1 ? `${f.values[0]} +${f.values.length - 1}` : f.values[0]
|
||||
return `${dim} ${op} ${val}`
|
||||
}
|
||||
@@ -35,14 +35,14 @@ import type {
|
||||
const fetchers = {
|
||||
site: (siteId: string) => getSite(siteId),
|
||||
dashboard: (siteId: string, start: string, end: string) => getDashboard(siteId, start, end),
|
||||
dashboardOverview: (siteId: string, start: string, end: string, interval?: string) => getDashboardOverview(siteId, start, end, interval),
|
||||
dashboardPages: (siteId: string, start: string, end: string) => getDashboardPages(siteId, start, end),
|
||||
dashboardLocations: (siteId: string, start: string, end: string) => getDashboardLocations(siteId, start, end),
|
||||
dashboardDevices: (siteId: string, start: string, end: string) => getDashboardDevices(siteId, start, end),
|
||||
dashboardReferrers: (siteId: string, start: string, end: string) => getDashboardReferrers(siteId, start, end),
|
||||
dashboardPerformance: (siteId: string, start: string, end: string) => getDashboardPerformance(siteId, start, end),
|
||||
dashboardGoals: (siteId: string, start: string, end: string) => getDashboardGoals(siteId, start, end),
|
||||
stats: (siteId: string, start: string, end: string) => getStats(siteId, start, end),
|
||||
dashboardOverview: (siteId: string, start: string, end: string, interval?: string, filters?: string) => getDashboardOverview(siteId, start, end, interval, filters),
|
||||
dashboardPages: (siteId: string, start: string, end: string, filters?: string) => getDashboardPages(siteId, start, end, undefined, filters),
|
||||
dashboardLocations: (siteId: string, start: string, end: string, filters?: string) => getDashboardLocations(siteId, start, end, undefined, undefined, filters),
|
||||
dashboardDevices: (siteId: string, start: string, end: string, filters?: string) => getDashboardDevices(siteId, start, end, undefined, filters),
|
||||
dashboardReferrers: (siteId: string, start: string, end: string, filters?: string) => getDashboardReferrers(siteId, start, end, undefined, filters),
|
||||
dashboardPerformance: (siteId: string, start: string, end: string, filters?: string) => getDashboardPerformance(siteId, start, end, filters),
|
||||
dashboardGoals: (siteId: string, start: string, end: string, filters?: string) => getDashboardGoals(siteId, start, end, undefined, filters),
|
||||
stats: (siteId: string, start: string, end: string, filters?: string) => getStats(siteId, start, end, filters),
|
||||
dailyStats: (siteId: string, start: string, end: string, interval: 'hour' | 'day' | 'minute') =>
|
||||
getDailyStats(siteId, start, end, interval),
|
||||
realtime: (siteId: string) => getRealtime(siteId),
|
||||
@@ -94,10 +94,10 @@ export function useDashboard(siteId: string, start: string, end: string) {
|
||||
}
|
||||
|
||||
// * Hook for stats (refreshed less frequently)
|
||||
export function useStats(siteId: string, start: string, end: string) {
|
||||
export function useStats(siteId: string, start: string, end: string, filters?: string) {
|
||||
return useSWR<Stats>(
|
||||
siteId && start && end ? ['stats', siteId, start, end] : null,
|
||||
() => fetchers.stats(siteId, start, end),
|
||||
siteId && start && end ? ['stats', siteId, start, end, filters] : null,
|
||||
() => fetchers.stats(siteId, start, end, filters),
|
||||
{
|
||||
...dashboardSWRConfig,
|
||||
// * Refresh every 60 seconds for stats
|
||||
@@ -144,10 +144,10 @@ export function useRealtime(siteId: string, refreshInterval: number = 5000) {
|
||||
}
|
||||
|
||||
// * Hook for focused dashboard overview data (Fix 4.2: Efficient Data Transfer)
|
||||
export function useDashboardOverview(siteId: string, start: string, end: string, interval?: string) {
|
||||
export function useDashboardOverview(siteId: string, start: string, end: string, interval?: string, filters?: string) {
|
||||
return useSWR<DashboardOverviewData>(
|
||||
siteId && start && end ? ['dashboardOverview', siteId, start, end, interval] : null,
|
||||
() => fetchers.dashboardOverview(siteId, start, end, interval),
|
||||
siteId && start && end ? ['dashboardOverview', siteId, start, end, interval, filters] : null,
|
||||
() => fetchers.dashboardOverview(siteId, start, end, interval, filters),
|
||||
{
|
||||
...dashboardSWRConfig,
|
||||
refreshInterval: 60 * 1000,
|
||||
@@ -157,10 +157,10 @@ export function useDashboardOverview(siteId: string, start: string, end: string,
|
||||
}
|
||||
|
||||
// * Hook for focused dashboard pages data
|
||||
export function useDashboardPages(siteId: string, start: string, end: string) {
|
||||
export function useDashboardPages(siteId: string, start: string, end: string, filters?: string) {
|
||||
return useSWR<DashboardPagesData>(
|
||||
siteId && start && end ? ['dashboardPages', siteId, start, end] : null,
|
||||
() => fetchers.dashboardPages(siteId, start, end),
|
||||
siteId && start && end ? ['dashboardPages', siteId, start, end, filters] : null,
|
||||
() => fetchers.dashboardPages(siteId, start, end, filters),
|
||||
{
|
||||
...dashboardSWRConfig,
|
||||
refreshInterval: 60 * 1000,
|
||||
@@ -170,10 +170,10 @@ export function useDashboardPages(siteId: string, start: string, end: string) {
|
||||
}
|
||||
|
||||
// * Hook for focused dashboard locations data
|
||||
export function useDashboardLocations(siteId: string, start: string, end: string) {
|
||||
export function useDashboardLocations(siteId: string, start: string, end: string, filters?: string) {
|
||||
return useSWR<DashboardLocationsData>(
|
||||
siteId && start && end ? ['dashboardLocations', siteId, start, end] : null,
|
||||
() => fetchers.dashboardLocations(siteId, start, end),
|
||||
siteId && start && end ? ['dashboardLocations', siteId, start, end, filters] : null,
|
||||
() => fetchers.dashboardLocations(siteId, start, end, filters),
|
||||
{
|
||||
...dashboardSWRConfig,
|
||||
refreshInterval: 60 * 1000,
|
||||
@@ -183,10 +183,10 @@ export function useDashboardLocations(siteId: string, start: string, end: string
|
||||
}
|
||||
|
||||
// * Hook for focused dashboard devices data
|
||||
export function useDashboardDevices(siteId: string, start: string, end: string) {
|
||||
export function useDashboardDevices(siteId: string, start: string, end: string, filters?: string) {
|
||||
return useSWR<DashboardDevicesData>(
|
||||
siteId && start && end ? ['dashboardDevices', siteId, start, end] : null,
|
||||
() => fetchers.dashboardDevices(siteId, start, end),
|
||||
siteId && start && end ? ['dashboardDevices', siteId, start, end, filters] : null,
|
||||
() => fetchers.dashboardDevices(siteId, start, end, filters),
|
||||
{
|
||||
...dashboardSWRConfig,
|
||||
refreshInterval: 60 * 1000,
|
||||
@@ -196,10 +196,10 @@ export function useDashboardDevices(siteId: string, start: string, end: string)
|
||||
}
|
||||
|
||||
// * Hook for focused dashboard referrers data
|
||||
export function useDashboardReferrers(siteId: string, start: string, end: string) {
|
||||
export function useDashboardReferrers(siteId: string, start: string, end: string, filters?: string) {
|
||||
return useSWR<DashboardReferrersData>(
|
||||
siteId && start && end ? ['dashboardReferrers', siteId, start, end] : null,
|
||||
() => fetchers.dashboardReferrers(siteId, start, end),
|
||||
siteId && start && end ? ['dashboardReferrers', siteId, start, end, filters] : null,
|
||||
() => fetchers.dashboardReferrers(siteId, start, end, filters),
|
||||
{
|
||||
...dashboardSWRConfig,
|
||||
refreshInterval: 60 * 1000,
|
||||
@@ -209,10 +209,10 @@ export function useDashboardReferrers(siteId: string, start: string, end: string
|
||||
}
|
||||
|
||||
// * Hook for focused dashboard performance data
|
||||
export function useDashboardPerformance(siteId: string, start: string, end: string) {
|
||||
export function useDashboardPerformance(siteId: string, start: string, end: string, filters?: string) {
|
||||
return useSWR<DashboardPerformanceData>(
|
||||
siteId && start && end ? ['dashboardPerformance', siteId, start, end] : null,
|
||||
() => fetchers.dashboardPerformance(siteId, start, end),
|
||||
siteId && start && end ? ['dashboardPerformance', siteId, start, end, filters] : null,
|
||||
() => fetchers.dashboardPerformance(siteId, start, end, filters),
|
||||
{
|
||||
...dashboardSWRConfig,
|
||||
refreshInterval: 60 * 1000,
|
||||
@@ -222,10 +222,10 @@ export function useDashboardPerformance(siteId: string, start: string, end: stri
|
||||
}
|
||||
|
||||
// * Hook for focused dashboard goals data
|
||||
export function useDashboardGoals(siteId: string, start: string, end: string) {
|
||||
export function useDashboardGoals(siteId: string, start: string, end: string, filters?: string) {
|
||||
return useSWR<DashboardGoalsData>(
|
||||
siteId && start && end ? ['dashboardGoals', siteId, start, end] : null,
|
||||
() => fetchers.dashboardGoals(siteId, start, end),
|
||||
siteId && start && end ? ['dashboardGoals', siteId, start, end, filters] : null,
|
||||
() => fetchers.dashboardGoals(siteId, start, end, filters),
|
||||
{
|
||||
...dashboardSWRConfig,
|
||||
refreshInterval: 60 * 1000,
|
||||
|
||||
@@ -322,7 +322,7 @@
|
||||
var EVENT_NAME_MAX = 64;
|
||||
var EVENT_NAME_REGEX = /^[a-zA-Z0-9_]+$/;
|
||||
|
||||
function trackCustomEvent(eventName) {
|
||||
function trackCustomEvent(eventName, props) {
|
||||
if (typeof eventName !== 'string' || !eventName.trim()) return;
|
||||
var name = eventName.trim().toLowerCase();
|
||||
if (name.length > EVENT_NAME_MAX || !EVENT_NAME_REGEX.test(name)) {
|
||||
@@ -342,6 +342,20 @@
|
||||
session_id: getSessionId(),
|
||||
name: name,
|
||||
};
|
||||
// * Attach custom properties if provided (max 30 props, key max 200 chars, value max 2000 chars)
|
||||
if (props && typeof props === 'object' && !Array.isArray(props)) {
|
||||
var sanitized = {};
|
||||
var count = 0;
|
||||
for (var key in props) {
|
||||
if (!props.hasOwnProperty(key)) continue;
|
||||
if (count >= 30) break;
|
||||
var k = String(key).substring(0, 200);
|
||||
var v = String(props[key]).substring(0, 2000);
|
||||
sanitized[k] = v;
|
||||
count++;
|
||||
}
|
||||
if (count > 0) payload.props = sanitized;
|
||||
}
|
||||
fetch(apiUrl + '/api/v1/events', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
|
||||
Reference in New Issue
Block a user