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:
Usman Baig
2026-03-06 21:02:14 +01:00
parent 8b1d196812
commit 5677f30f3b
10 changed files with 497 additions and 66 deletions

View File

@@ -10,6 +10,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
### Added ### 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. - **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 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. - **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 [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.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.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 [0.11.0-alpha]: https://github.com/ciphera-net/pulse/compare/v0.10.0-alpha...v0.11.0-alpha

View File

@@ -2,8 +2,8 @@
import { useAuth } from '@/lib/auth/context' import { useAuth } from '@/lib/auth/context'
import { logger } from '@/lib/utils/logger' import { logger } from '@/lib/utils/logger'
import { useEffect, useState, useMemo } from 'react' import { useCallback, useEffect, useState, useMemo } from 'react'
import { useParams, useRouter } from 'next/navigation' import { useParams, useRouter, useSearchParams } from 'next/navigation'
import { motion } from 'framer-motion' import { motion } from 'framer-motion'
import { getPerformanceByPage, type Stats, type DailyStat } from '@/lib/api/stats' import { getPerformanceByPage, type Stats, type DailyStat } from '@/lib/api/stats'
import { getDateRange } from '@ciphera-net/ui' import { getDateRange } from '@ciphera-net/ui'
@@ -21,6 +21,10 @@ import PerformanceStats from '@/components/dashboard/PerformanceStats'
import GoalStats from '@/components/dashboard/GoalStats' import GoalStats from '@/components/dashboard/GoalStats'
import ScrollDepth from '@/components/dashboard/ScrollDepth' import ScrollDepth from '@/components/dashboard/ScrollDepth'
import Campaigns from '@/components/dashboard/Campaigns' 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 { import {
useDashboardOverview, useDashboardOverview,
useDashboardPages, useDashboardPages,
@@ -82,6 +86,40 @@ export default function SiteDashboardPage() {
const [lastUpdatedAt, setLastUpdatedAt] = useState<number | null>(null) const [lastUpdatedAt, setLastUpdatedAt] = useState<number | null>(null)
const [, setTick] = useState(0) 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 const interval = dateRange.start === dateRange.end ? todayInterval : multiDayInterval
// Previous period date range for comparison // Previous period date range for comparison
@@ -100,13 +138,14 @@ export default function SiteDashboardPage() {
// SWR hooks - replace manual useState + useEffect + setInterval polling // SWR hooks - replace manual useState + useEffect + setInterval polling
// Each hook handles its own refresh interval, deduplication, and error retry // 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) // Filters are included in cache keys so changing filters auto-refetches
const { data: pages } = useDashboardPages(siteId, dateRange.start, dateRange.end) const { data: overview, isLoading: overviewLoading, error: overviewError } = useDashboardOverview(siteId, dateRange.start, dateRange.end, interval, filtersParam || undefined)
const { data: locations } = useDashboardLocations(siteId, dateRange.start, dateRange.end) const { data: pages } = useDashboardPages(siteId, dateRange.start, dateRange.end, filtersParam || undefined)
const { data: devicesData } = useDashboardDevices(siteId, dateRange.start, dateRange.end) const { data: locations } = useDashboardLocations(siteId, dateRange.start, dateRange.end, filtersParam || undefined)
const { data: referrers } = useDashboardReferrers(siteId, dateRange.start, dateRange.end) const { data: devicesData } = useDashboardDevices(siteId, dateRange.start, dateRange.end, filtersParam || undefined)
const { data: performanceData } = useDashboardPerformance(siteId, dateRange.start, dateRange.end) const { data: referrers } = useDashboardReferrers(siteId, dateRange.start, dateRange.end, filtersParam || undefined)
const { data: goalsData } = useDashboardGoals(siteId, dateRange.start, dateRange.end) 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: realtimeData } = useRealtime(siteId)
const { data: prevStats } = useStats(siteId, prevRange.start, prevRange.end) const { data: prevStats } = useStats(siteId, prevRange.start, prevRange.end)
const { data: prevDailyStats } = useDailyStats(siteId, prevRange.start, prevRange.end, interval) const { data: prevDailyStats } = useDailyStats(siteId, prevRange.start, prevRange.end, interval)
@@ -306,6 +345,12 @@ export default function SiteDashboardPage() {
</div> </div>
</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 */} {/* Advanced Chart with Integrated Stats */}
<div className="mb-8"> <div className="mb-8">
<Chart <Chart
@@ -382,10 +427,25 @@ export default function SiteDashboardPage() {
</div> </div>
<div className="grid gap-6 lg:grid-cols-2 mb-8"> <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} /> <ScrollDepth goalCounts={goalsData?.goal_counts ?? []} totalPageviews={stats.pageviews} />
</div> </div>
{/* Event Properties Breakdown */}
{selectedEvent && (
<div className="mb-8">
<EventProperties
siteId={siteId}
eventName={selectedEvent}
dateRange={dateRange}
onClose={() => setSelectedEvent(null)}
/>
</div>
)}
<DatePicker <DatePicker
isOpen={isDatePickerOpen} isOpen={isDatePickerOpen}
onClose={() => setIsDatePickerOpen(false)} onClose={() => setIsDatePickerOpen(false)}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View File

@@ -7,11 +7,12 @@ import type { GoalCountStat } from '@/lib/api/stats'
interface GoalStatsProps { interface GoalStatsProps {
goalCounts: GoalCountStat[] goalCounts: GoalCountStat[]
onSelectEvent?: (eventName: string) => void
} }
const LIMIT = 10 const LIMIT = 10
export default function GoalStats({ goalCounts }: GoalStatsProps) { export default function GoalStats({ goalCounts, onSelectEvent }: GoalStatsProps) {
const list = (goalCounts || []).slice(0, LIMIT) const list = (goalCounts || []).slice(0, LIMIT)
const hasData = list.length > 0 const hasData = list.length > 0
@@ -28,7 +29,8 @@ export default function GoalStats({ goalCounts }: GoalStatsProps) {
{list.map((row) => ( {list.map((row) => (
<div <div
key={row.event_name} 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"> <span className="text-sm font-medium text-neutral-900 dark:text-white truncate">
{row.display_name ?? row.event_name.replace(/_/g, ' ')} {row.display_name ?? row.event_name.replace(/_/g, ' ')}

View File

@@ -120,6 +120,7 @@ function buildQuery(
interval?: string interval?: string
countryLimit?: number countryLimit?: number
sort?: string sort?: string
filters?: string
}, },
auth?: AuthParams auth?: AuthParams
): string { ): string {
@@ -130,6 +131,7 @@ function buildQuery(
if (opts.interval) params.append('interval', opts.interval) if (opts.interval) params.append('interval', opts.interval)
if (opts.countryLimit != null) params.append('country_limit', opts.countryLimit.toString()) if (opts.countryLimit != null) params.append('country_limit', opts.countryLimit.toString())
if (opts.sort) params.append('sort', opts.sort) if (opts.sort) params.append('sort', opts.sort)
if (opts.filters) params.append('filters', opts.filters)
if (auth) appendAuthParams(params, auth) if (auth) appendAuthParams(params, auth)
const query = params.toString() const query = params.toString()
return query ? `?${query}` : '' return query ? `?${query}` : ''
@@ -137,8 +139,8 @@ function buildQuery(
/** Factory for endpoints that return an array nested under a response key. */ /** Factory for endpoints that return an array nested under a response key. */
function createListFetcher<T>(path: string, field: string, defaultLimit = 10) { function createListFetcher<T>(path: string, field: string, defaultLimit = 10) {
return (siteId: string, startDate?: string, endDate?: string, limit = defaultLimit): Promise<T[]> => return (siteId: string, startDate?: string, endDate?: string, limit = defaultLimit, filters?: string): Promise<T[]> =>
apiRequest<Record<string, T[]>>(`/sites/${siteId}/${path}${buildQuery({ startDate, endDate, limit })}`) apiRequest<Record<string, T[]>>(`/sites/${siteId}/${path}${buildQuery({ startDate, endDate, limit, filters })}`)
.then(r => r?.[field] || []) .then(r => r?.[field] || [])
} }
@@ -160,8 +162,8 @@ export const getCampaigns = createListFetcher<CampaignStat>('campaigns', 'campai
// ─── Stats & Realtime ─────────────────────────────────────────────── // ─── Stats & Realtime ───────────────────────────────────────────────
export function getStats(siteId: string, startDate?: string, endDate?: string): Promise<Stats> { export function getStats(siteId: string, startDate?: string, endDate?: string, filters?: string): Promise<Stats> {
return apiRequest<Stats>(`/sites/${siteId}/stats${buildQuery({ startDate, endDate })}`) return apiRequest<Stats>(`/sites/${siteId}/stats${buildQuery({ startDate, endDate, filters })}`)
} }
export function getPublicStats(siteId: string, startDate?: string, endDate?: string, auth?: AuthParams): Promise<Stats> { 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 ──────────────────────────────────────────────────── // ─── Daily Stats ────────────────────────────────────────────────────
export function getDailyStats(siteId: string, startDate?: string, endDate?: string, interval?: string): Promise<DailyStat[]> { 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 })}`) return apiRequest<{ stats: DailyStat[] }>(`/sites/${siteId}/daily${buildQuery({ startDate, endDate, interval, filters })}`)
.then(r => r?.stats || []) .then(r => r?.stats || [])
} }
@@ -302,8 +304,8 @@ export interface DashboardGoalsData {
goal_counts: GoalCountStat[] goal_counts: GoalCountStat[]
} }
export function getDashboardOverview(siteId: string, startDate?: string, endDate?: string, interval?: string): Promise<DashboardOverviewData> { 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 })}`) return apiRequest<DashboardOverviewData>(`/sites/${siteId}/dashboard/overview${buildQuery({ startDate, endDate, interval, filters })}`)
} }
export function getPublicDashboardOverview( export function getPublicDashboardOverview(
@@ -313,8 +315,8 @@ export function getPublicDashboardOverview(
return apiRequest<DashboardOverviewData>(`/public/sites/${siteId}/dashboard/overview${buildQuery({ startDate, endDate, interval }, { password, captcha })}`) 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> { 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 })}`) return apiRequest<DashboardPagesData>(`/sites/${siteId}/dashboard/pages${buildQuery({ startDate, endDate, limit, filters })}`)
} }
export function getPublicDashboardPages( export function getPublicDashboardPages(
@@ -324,8 +326,8 @@ export function getPublicDashboardPages(
return apiRequest<DashboardPagesData>(`/public/sites/${siteId}/dashboard/pages${buildQuery({ startDate, endDate, limit }, { password, captcha })}`) 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> { 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 })}`) return apiRequest<DashboardLocationsData>(`/sites/${siteId}/dashboard/locations${buildQuery({ startDate, endDate, limit, countryLimit, filters })}`)
} }
export function getPublicDashboardLocations( 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 })}`) 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> { 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 })}`) return apiRequest<DashboardDevicesData>(`/sites/${siteId}/dashboard/devices${buildQuery({ startDate, endDate, limit, filters })}`)
} }
export function getPublicDashboardDevices( export function getPublicDashboardDevices(
@@ -346,8 +348,8 @@ export function getPublicDashboardDevices(
return apiRequest<DashboardDevicesData>(`/public/sites/${siteId}/dashboard/devices${buildQuery({ startDate, endDate, limit }, { password, captcha })}`) 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> { 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 })}`) return apiRequest<DashboardReferrersData>(`/sites/${siteId}/dashboard/referrers${buildQuery({ startDate, endDate, limit, filters })}`)
} }
export function getPublicDashboardReferrers( export function getPublicDashboardReferrers(
@@ -357,8 +359,8 @@ export function getPublicDashboardReferrers(
return apiRequest<DashboardReferrersData>(`/public/sites/${siteId}/dashboard/referrers${buildQuery({ startDate, endDate, limit }, { password, captcha })}`) 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> { export function getDashboardPerformance(siteId: string, startDate?: string, endDate?: string, filters?: string): Promise<DashboardPerformanceData> {
return apiRequest<DashboardPerformanceData>(`/sites/${siteId}/dashboard/performance${buildQuery({ startDate, endDate })}`) return apiRequest<DashboardPerformanceData>(`/sites/${siteId}/dashboard/performance${buildQuery({ startDate, endDate, filters })}`)
} }
export function getPublicDashboardPerformance( export function getPublicDashboardPerformance(
@@ -368,8 +370,8 @@ export function getPublicDashboardPerformance(
return apiRequest<DashboardPerformanceData>(`/public/sites/${siteId}/dashboard/performance${buildQuery({ startDate, endDate }, { password, captcha })}`) 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> { 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 })}`) return apiRequest<DashboardGoalsData>(`/sites/${siteId}/dashboard/goals${buildQuery({ startDate, endDate, limit, filters })}`)
} }
export function getPublicDashboardGoals( export function getPublicDashboardGoals(
@@ -378,3 +380,25 @@ export function getPublicDashboardGoals(
): Promise<DashboardGoalsData> { ): Promise<DashboardGoalsData> {
return apiRequest<DashboardGoalsData>(`/public/sites/${siteId}/dashboard/goals${buildQuery({ startDate, endDate, limit }, { password, captcha })}`) 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
View 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}`
}

View File

@@ -35,14 +35,14 @@ import type {
const fetchers = { const fetchers = {
site: (siteId: string) => getSite(siteId), site: (siteId: string) => getSite(siteId),
dashboard: (siteId: string, start: string, end: string) => getDashboard(siteId, start, end), 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), dashboardOverview: (siteId: string, start: string, end: string, interval?: string, filters?: string) => getDashboardOverview(siteId, start, end, interval, filters),
dashboardPages: (siteId: string, start: string, end: string) => getDashboardPages(siteId, start, end), dashboardPages: (siteId: string, start: string, end: string, filters?: string) => getDashboardPages(siteId, start, end, undefined, filters),
dashboardLocations: (siteId: string, start: string, end: string) => getDashboardLocations(siteId, start, end), dashboardLocations: (siteId: string, start: string, end: string, filters?: string) => getDashboardLocations(siteId, start, end, undefined, undefined, filters),
dashboardDevices: (siteId: string, start: string, end: string) => getDashboardDevices(siteId, start, end), dashboardDevices: (siteId: string, start: string, end: string, filters?: string) => getDashboardDevices(siteId, start, end, undefined, filters),
dashboardReferrers: (siteId: string, start: string, end: string) => getDashboardReferrers(siteId, start, end), dashboardReferrers: (siteId: string, start: string, end: string, filters?: string) => getDashboardReferrers(siteId, start, end, undefined, filters),
dashboardPerformance: (siteId: string, start: string, end: string) => getDashboardPerformance(siteId, start, end), dashboardPerformance: (siteId: string, start: string, end: string, filters?: string) => getDashboardPerformance(siteId, start, end, filters),
dashboardGoals: (siteId: string, start: string, end: string) => getDashboardGoals(siteId, start, end), dashboardGoals: (siteId: string, start: string, end: string, filters?: string) => getDashboardGoals(siteId, start, end, undefined, filters),
stats: (siteId: string, start: string, end: string) => getStats(siteId, start, end), 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') => dailyStats: (siteId: string, start: string, end: string, interval: 'hour' | 'day' | 'minute') =>
getDailyStats(siteId, start, end, interval), getDailyStats(siteId, start, end, interval),
realtime: (siteId: string) => getRealtime(siteId), realtime: (siteId: string) => getRealtime(siteId),
@@ -94,10 +94,10 @@ export function useDashboard(siteId: string, start: string, end: string) {
} }
// * Hook for stats (refreshed less frequently) // * 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>( return useSWR<Stats>(
siteId && start && end ? ['stats', siteId, start, end] : null, siteId && start && end ? ['stats', siteId, start, end, filters] : null,
() => fetchers.stats(siteId, start, end), () => fetchers.stats(siteId, start, end, filters),
{ {
...dashboardSWRConfig, ...dashboardSWRConfig,
// * Refresh every 60 seconds for stats // * 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) // * 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>( return useSWR<DashboardOverviewData>(
siteId && start && end ? ['dashboardOverview', siteId, start, end, interval] : null, siteId && start && end ? ['dashboardOverview', siteId, start, end, interval, filters] : null,
() => fetchers.dashboardOverview(siteId, start, end, interval), () => fetchers.dashboardOverview(siteId, start, end, interval, filters),
{ {
...dashboardSWRConfig, ...dashboardSWRConfig,
refreshInterval: 60 * 1000, refreshInterval: 60 * 1000,
@@ -157,10 +157,10 @@ export function useDashboardOverview(siteId: string, start: string, end: string,
} }
// * Hook for focused dashboard pages data // * 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>( return useSWR<DashboardPagesData>(
siteId && start && end ? ['dashboardPages', siteId, start, end] : null, siteId && start && end ? ['dashboardPages', siteId, start, end, filters] : null,
() => fetchers.dashboardPages(siteId, start, end), () => fetchers.dashboardPages(siteId, start, end, filters),
{ {
...dashboardSWRConfig, ...dashboardSWRConfig,
refreshInterval: 60 * 1000, refreshInterval: 60 * 1000,
@@ -170,10 +170,10 @@ export function useDashboardPages(siteId: string, start: string, end: string) {
} }
// * Hook for focused dashboard locations data // * 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>( return useSWR<DashboardLocationsData>(
siteId && start && end ? ['dashboardLocations', siteId, start, end] : null, siteId && start && end ? ['dashboardLocations', siteId, start, end, filters] : null,
() => fetchers.dashboardLocations(siteId, start, end), () => fetchers.dashboardLocations(siteId, start, end, filters),
{ {
...dashboardSWRConfig, ...dashboardSWRConfig,
refreshInterval: 60 * 1000, refreshInterval: 60 * 1000,
@@ -183,10 +183,10 @@ export function useDashboardLocations(siteId: string, start: string, end: string
} }
// * Hook for focused dashboard devices data // * 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>( return useSWR<DashboardDevicesData>(
siteId && start && end ? ['dashboardDevices', siteId, start, end] : null, siteId && start && end ? ['dashboardDevices', siteId, start, end, filters] : null,
() => fetchers.dashboardDevices(siteId, start, end), () => fetchers.dashboardDevices(siteId, start, end, filters),
{ {
...dashboardSWRConfig, ...dashboardSWRConfig,
refreshInterval: 60 * 1000, refreshInterval: 60 * 1000,
@@ -196,10 +196,10 @@ export function useDashboardDevices(siteId: string, start: string, end: string)
} }
// * Hook for focused dashboard referrers data // * 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>( return useSWR<DashboardReferrersData>(
siteId && start && end ? ['dashboardReferrers', siteId, start, end] : null, siteId && start && end ? ['dashboardReferrers', siteId, start, end, filters] : null,
() => fetchers.dashboardReferrers(siteId, start, end), () => fetchers.dashboardReferrers(siteId, start, end, filters),
{ {
...dashboardSWRConfig, ...dashboardSWRConfig,
refreshInterval: 60 * 1000, refreshInterval: 60 * 1000,
@@ -209,10 +209,10 @@ export function useDashboardReferrers(siteId: string, start: string, end: string
} }
// * Hook for focused dashboard performance data // * 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>( return useSWR<DashboardPerformanceData>(
siteId && start && end ? ['dashboardPerformance', siteId, start, end] : null, siteId && start && end ? ['dashboardPerformance', siteId, start, end, filters] : null,
() => fetchers.dashboardPerformance(siteId, start, end), () => fetchers.dashboardPerformance(siteId, start, end, filters),
{ {
...dashboardSWRConfig, ...dashboardSWRConfig,
refreshInterval: 60 * 1000, refreshInterval: 60 * 1000,
@@ -222,10 +222,10 @@ export function useDashboardPerformance(siteId: string, start: string, end: stri
} }
// * Hook for focused dashboard goals data // * 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>( return useSWR<DashboardGoalsData>(
siteId && start && end ? ['dashboardGoals', siteId, start, end] : null, siteId && start && end ? ['dashboardGoals', siteId, start, end, filters] : null,
() => fetchers.dashboardGoals(siteId, start, end), () => fetchers.dashboardGoals(siteId, start, end, filters),
{ {
...dashboardSWRConfig, ...dashboardSWRConfig,
refreshInterval: 60 * 1000, refreshInterval: 60 * 1000,

View File

@@ -322,7 +322,7 @@
var EVENT_NAME_MAX = 64; var EVENT_NAME_MAX = 64;
var EVENT_NAME_REGEX = /^[a-zA-Z0-9_]+$/; var EVENT_NAME_REGEX = /^[a-zA-Z0-9_]+$/;
function trackCustomEvent(eventName) { function trackCustomEvent(eventName, props) {
if (typeof eventName !== 'string' || !eventName.trim()) return; if (typeof eventName !== 'string' || !eventName.trim()) return;
var name = eventName.trim().toLowerCase(); var name = eventName.trim().toLowerCase();
if (name.length > EVENT_NAME_MAX || !EVENT_NAME_REGEX.test(name)) { if (name.length > EVENT_NAME_MAX || !EVENT_NAME_REGEX.test(name)) {
@@ -342,6 +342,20 @@
session_id: getSessionId(), session_id: getSessionId(),
name: name, 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', { fetch(apiUrl + '/api/v1/events', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },