feat: replace filter modal with chip-based dimension filter bar
Dimension chips (Page, Referrer, Country, Browser, OS, Device + More) with popover dropdowns showing real values from current dashboard data with counts. Operators inline, search/type custom values, click to apply.
This commit is contained in:
@@ -22,7 +22,7 @@ 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 AddFilterDropdown, { type FilterSuggestions } from '@/components/dashboard/AddFilterDropdown'
|
||||
import EventProperties from '@/components/dashboard/EventProperties'
|
||||
import { type DimensionFilter, serializeFilters, parseFiltersFromURL } from '@/lib/filters'
|
||||
import {
|
||||
@@ -168,6 +168,109 @@ export default function SiteDashboardPage() {
|
||||
const realtime = realtimeData?.visitors ?? overview?.realtime_visitors ?? 0
|
||||
const dailyStats: DailyStat[] = overview?.daily_stats ?? []
|
||||
|
||||
// Build filter suggestions from current dashboard data
|
||||
const filterSuggestions = useMemo<FilterSuggestions>(() => {
|
||||
const s: FilterSuggestions = {}
|
||||
|
||||
// Pages
|
||||
const topPages = pages?.top_pages ?? []
|
||||
if (topPages.length > 0) {
|
||||
s.page = topPages.map(p => ({ value: p.path, label: p.path, count: p.pageviews }))
|
||||
}
|
||||
|
||||
// Referrers
|
||||
const refs = referrers?.top_referrers ?? []
|
||||
if (refs.length > 0) {
|
||||
s.referrer = [
|
||||
{ value: '', label: 'Direct', count: undefined },
|
||||
...refs.filter(r => r.referrer && r.referrer !== '').map(r => ({
|
||||
value: r.referrer,
|
||||
label: r.referrer,
|
||||
count: r.pageviews,
|
||||
})),
|
||||
]
|
||||
}
|
||||
|
||||
// Countries
|
||||
const ctrs = locations?.countries ?? []
|
||||
if (ctrs.length > 0) {
|
||||
const regionNames = (() => { try { return new Intl.DisplayNames(['en'], { type: 'region' }) } catch { return null } })()
|
||||
s.country = ctrs.filter(c => c.country && c.country !== 'Unknown').map(c => ({
|
||||
value: c.country,
|
||||
label: regionNames?.of(c.country) ?? c.country,
|
||||
count: c.pageviews,
|
||||
}))
|
||||
}
|
||||
|
||||
// Regions
|
||||
const regs = locations?.regions ?? []
|
||||
if (regs.length > 0) {
|
||||
s.region = regs.filter(r => r.region && r.region !== 'Unknown').map(r => ({
|
||||
value: r.region,
|
||||
label: r.region,
|
||||
count: r.pageviews,
|
||||
}))
|
||||
}
|
||||
|
||||
// Cities
|
||||
const cts = locations?.cities ?? []
|
||||
if (cts.length > 0) {
|
||||
s.city = cts.filter(c => c.city && c.city !== 'Unknown').map(c => ({
|
||||
value: c.city,
|
||||
label: c.city,
|
||||
count: c.pageviews,
|
||||
}))
|
||||
}
|
||||
|
||||
// Browsers
|
||||
const brs = devicesData?.browsers ?? []
|
||||
if (brs.length > 0) {
|
||||
s.browser = brs.filter(b => b.browser && b.browser !== 'Unknown').map(b => ({
|
||||
value: b.browser,
|
||||
label: b.browser,
|
||||
count: b.pageviews,
|
||||
}))
|
||||
}
|
||||
|
||||
// OS
|
||||
const oses = devicesData?.os ?? []
|
||||
if (oses.length > 0) {
|
||||
s.os = oses.filter(o => o.os && o.os !== 'Unknown').map(o => ({
|
||||
value: o.os,
|
||||
label: o.os,
|
||||
count: o.pageviews,
|
||||
}))
|
||||
}
|
||||
|
||||
// Devices
|
||||
const devs = devicesData?.devices ?? []
|
||||
if (devs.length > 0) {
|
||||
s.device = devs.filter(d => d.device && d.device !== 'Unknown').map(d => ({
|
||||
value: d.device,
|
||||
label: d.device,
|
||||
count: d.pageviews,
|
||||
}))
|
||||
}
|
||||
|
||||
// UTM from campaigns
|
||||
const camps = campaigns ?? []
|
||||
if (camps.length > 0) {
|
||||
const sources = new Map<string, number>()
|
||||
const mediums = new Map<string, number>()
|
||||
const campNames = new Map<string, number>()
|
||||
camps.forEach(c => {
|
||||
if (c.source) sources.set(c.source, (sources.get(c.source) ?? 0) + c.pageviews)
|
||||
if (c.medium) mediums.set(c.medium, (mediums.get(c.medium) ?? 0) + c.pageviews)
|
||||
if (c.campaign) campNames.set(c.campaign, (campNames.get(c.campaign) ?? 0) + c.pageviews)
|
||||
})
|
||||
if (sources.size > 0) s.utm_source = [...sources.entries()].map(([v, c]) => ({ value: v, label: v, count: c }))
|
||||
if (mediums.size > 0) s.utm_medium = [...mediums.entries()].map(([v, c]) => ({ value: v, label: v, count: c }))
|
||||
if (campNames.size > 0) s.utm_campaign = [...campNames.entries()].map(([v, c]) => ({ value: v, label: v, count: c }))
|
||||
}
|
||||
|
||||
return s
|
||||
}, [pages, referrers, locations, devicesData, campaigns])
|
||||
|
||||
// Show error toast on fetch failure
|
||||
useEffect(() => {
|
||||
if (overviewError) {
|
||||
@@ -357,9 +460,9 @@ export default function SiteDashboardPage() {
|
||||
</div>
|
||||
|
||||
{/* Dimension Filters */}
|
||||
<div className="flex items-center gap-2 flex-wrap mb-2">
|
||||
<div className="space-y-2 mb-2">
|
||||
<AddFilterDropdown onAdd={handleAddFilter} suggestions={filterSuggestions} />
|
||||
<FilterBar filters={filters} onRemove={handleRemoveFilter} onClear={handleClearFilters} />
|
||||
<AddFilterDropdown onAdd={handleAddFilter} />
|
||||
</div>
|
||||
|
||||
{/* Advanced Chart with Integrated Stats */}
|
||||
|
||||
@@ -1,119 +1,253 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Modal } from '@ciphera-net/ui'
|
||||
import { DIMENSIONS, DIMENSION_LABELS, OPERATORS, OPERATOR_LABELS, type DimensionFilter } from '@/lib/filters'
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { DIMENSION_LABELS, OPERATORS, OPERATOR_LABELS, type DimensionFilter } from '@/lib/filters'
|
||||
|
||||
export interface FilterSuggestion {
|
||||
value: string
|
||||
label: string
|
||||
count?: number
|
||||
}
|
||||
|
||||
export interface FilterSuggestions {
|
||||
[dimension: string]: FilterSuggestion[]
|
||||
}
|
||||
|
||||
interface AddFilterDropdownProps {
|
||||
onAdd: (filter: DimensionFilter) => void
|
||||
suggestions?: FilterSuggestions
|
||||
}
|
||||
|
||||
export default function AddFilterDropdown({ onAdd }: AddFilterDropdownProps) {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [dimension, setDimension] = useState('')
|
||||
// Which dimensions show as always-visible chips vs hidden in "More"
|
||||
const PRIMARY_DIMS = ['page', 'referrer', 'country', 'browser', 'os', 'device']
|
||||
const SECONDARY_DIMS = ['region', 'city', 'utm_source', 'utm_medium', 'utm_campaign']
|
||||
|
||||
function DimensionPopover({
|
||||
dimension,
|
||||
suggestions,
|
||||
onApply,
|
||||
onClose,
|
||||
}: {
|
||||
dimension: string
|
||||
suggestions: FilterSuggestion[]
|
||||
onApply: (filter: DimensionFilter) => void
|
||||
onClose: () => void
|
||||
}) {
|
||||
const [operator, setOperator] = useState<DimensionFilter['operator']>('is')
|
||||
const [value, setValue] = useState('')
|
||||
const [search, setSearch] = useState('')
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
function resetState() {
|
||||
setDimension('')
|
||||
setOperator('is')
|
||||
setValue('')
|
||||
useEffect(() => {
|
||||
inputRef.current?.focus()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
function handleClick(e: MouseEvent) {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) {
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
function handleEsc(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') onClose()
|
||||
}
|
||||
document.addEventListener('mousedown', handleClick)
|
||||
document.addEventListener('keydown', handleEsc)
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClick)
|
||||
document.removeEventListener('keydown', handleEsc)
|
||||
}
|
||||
}, [onClose])
|
||||
|
||||
const filtered = suggestions.filter(s =>
|
||||
s.label.toLowerCase().includes(search.toLowerCase()) ||
|
||||
s.value.toLowerCase().includes(search.toLowerCase())
|
||||
)
|
||||
|
||||
function handleSelectValue(value: string) {
|
||||
onApply({ dimension, operator, values: [value] })
|
||||
onClose()
|
||||
}
|
||||
|
||||
function handleOpen() {
|
||||
resetState()
|
||||
setIsOpen(true)
|
||||
function handleSubmitCustom() {
|
||||
if (!search.trim()) return
|
||||
onApply({ dimension, operator, values: [search.trim()] })
|
||||
onClose()
|
||||
}
|
||||
|
||||
function handleSubmit() {
|
||||
if (!dimension || !operator || !value.trim()) return
|
||||
onAdd({ dimension, operator, values: [value.trim()] })
|
||||
setIsOpen(false)
|
||||
resetState()
|
||||
}
|
||||
|
||||
const hasDimension = dimension !== ''
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
onClick={handleOpen}
|
||||
className="inline-flex items-center gap-2 px-3 py-1.5 text-xs font-medium rounded-lg bg-neutral-100 dark:bg-neutral-800 text-neutral-600 dark:text-neutral-400 hover:bg-neutral-200 dark:hover:bg-neutral-700 hover:text-neutral-900 dark:hover:text-white 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="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z" />
|
||||
</svg>
|
||||
Filter
|
||||
</button>
|
||||
<div
|
||||
ref={ref}
|
||||
className="absolute top-full left-0 mt-1.5 z-50 w-72 bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-700 rounded-xl shadow-xl overflow-hidden"
|
||||
>
|
||||
{/* Operator pills */}
|
||||
<div className="flex gap-1 px-3 pt-3 pb-2 flex-wrap">
|
||||
{OPERATORS.map(op => (
|
||||
<button
|
||||
key={op}
|
||||
onClick={() => setOperator(op)}
|
||||
className={`px-2.5 py-1 text-[11px] font-medium rounded-md transition-colors cursor-pointer ${
|
||||
operator === op
|
||||
? 'bg-brand-orange text-white'
|
||||
: 'bg-neutral-100 dark:bg-neutral-800 text-neutral-500 dark:text-neutral-400 hover:bg-neutral-200 dark:hover:bg-neutral-700'
|
||||
}`}
|
||||
>
|
||||
{OPERATOR_LABELS[op]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Modal isOpen={isOpen} onClose={() => setIsOpen(false)} title="Add Filter">
|
||||
{!hasDimension ? (
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{DIMENSIONS.map(dim => (
|
||||
{/* Search input */}
|
||||
<div className="px-3 pb-2">
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter') {
|
||||
if (filtered.length === 1) {
|
||||
handleSelectValue(filtered[0].value)
|
||||
} else {
|
||||
handleSubmitCustom()
|
||||
}
|
||||
}
|
||||
}}
|
||||
placeholder={`Search or type ${DIMENSION_LABELS[dimension]?.toLowerCase()}...`}
|
||||
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/40 focus:border-brand-orange transition-colors"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Suggestions list */}
|
||||
{filtered.length > 0 && (
|
||||
<div className="max-h-52 overflow-y-auto border-t border-neutral-100 dark:border-neutral-800">
|
||||
{filtered.map(s => (
|
||||
<button
|
||||
key={s.value}
|
||||
onClick={() => handleSelectValue(s.value)}
|
||||
className="w-full flex items-center justify-between px-3 py-2 text-sm text-left hover:bg-neutral-50 dark:hover:bg-neutral-800 transition-colors cursor-pointer"
|
||||
>
|
||||
<span className="truncate text-neutral-900 dark:text-white">{s.label}</span>
|
||||
{s.count !== undefined && (
|
||||
<span className="text-xs text-neutral-400 dark:text-neutral-500 ml-2 tabular-nums flex-shrink-0">
|
||||
{s.count.toLocaleString()}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Custom value apply when no matches */}
|
||||
{search.trim() && filtered.length === 0 && (
|
||||
<div className="px-3 py-3 border-t border-neutral-100 dark:border-neutral-800">
|
||||
<button
|
||||
onClick={handleSubmitCustom}
|
||||
className="w-full px-3 py-2 text-sm font-medium bg-brand-orange text-white rounded-lg hover:bg-brand-orange/90 transition-colors cursor-pointer"
|
||||
>
|
||||
Filter by “{search.trim()}”
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function AddFilterDropdown({ onAdd, suggestions = {} }: AddFilterDropdownProps) {
|
||||
const [openDim, setOpenDim] = useState<string | null>(null)
|
||||
const [showMore, setShowMore] = useState(false)
|
||||
const moreRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!showMore) return
|
||||
function handleClick(e: MouseEvent) {
|
||||
if (moreRef.current && !moreRef.current.contains(e.target as Node)) {
|
||||
setShowMore(false)
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handleClick)
|
||||
return () => document.removeEventListener('mousedown', handleClick)
|
||||
}, [showMore])
|
||||
|
||||
function renderChip(dim: string) {
|
||||
const isOpen = openDim === dim
|
||||
return (
|
||||
<div key={dim} className="relative">
|
||||
<button
|
||||
onClick={() => {
|
||||
setOpenDim(isOpen ? null : dim)
|
||||
setShowMore(false)
|
||||
}}
|
||||
className={`inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-lg transition-all cursor-pointer ${
|
||||
isOpen
|
||||
? 'bg-brand-orange/10 text-brand-orange border border-brand-orange/30'
|
||||
: 'bg-neutral-100 dark:bg-neutral-800 text-neutral-600 dark:text-neutral-400 hover:bg-neutral-200 dark:hover:bg-neutral-700 hover:text-neutral-900 dark:hover:text-white border border-transparent'
|
||||
}`}
|
||||
>
|
||||
{DIMENSION_LABELS[dim]}
|
||||
<svg className={`w-3 h-3 transition-transform ${isOpen ? 'rotate-180' : ''}`} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
{isOpen && (
|
||||
<DimensionPopover
|
||||
dimension={dim}
|
||||
suggestions={suggestions[dim] || []}
|
||||
onApply={onAdd}
|
||||
onClose={() => setOpenDim(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
{PRIMARY_DIMS.map(renderChip)}
|
||||
|
||||
{/* More dropdown for secondary dimensions */}
|
||||
<div className="relative" ref={moreRef}>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowMore(!showMore)
|
||||
setOpenDim(null)
|
||||
}}
|
||||
className={`inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-lg transition-all cursor-pointer ${
|
||||
showMore || SECONDARY_DIMS.includes(openDim || '')
|
||||
? 'bg-brand-orange/10 text-brand-orange border border-brand-orange/30'
|
||||
: 'bg-neutral-100 dark:bg-neutral-800 text-neutral-600 dark:text-neutral-400 hover:bg-neutral-200 dark:hover:bg-neutral-700 hover:text-neutral-900 dark:hover:text-white border border-transparent'
|
||||
}`}
|
||||
>
|
||||
More
|
||||
<svg className={`w-3 h-3 transition-transform ${showMore ? 'rotate-180' : ''}`} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
{showMore && (
|
||||
<div className="absolute top-full left-0 mt-1.5 z-50 bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-700 rounded-xl shadow-xl overflow-hidden py-1 min-w-[160px]">
|
||||
{SECONDARY_DIMS.map(dim => (
|
||||
<button
|
||||
key={dim}
|
||||
onClick={() => setDimension(dim)}
|
||||
className="text-left px-4 py-3 text-sm font-medium rounded-xl border border-neutral-200 dark:border-neutral-700 text-neutral-700 dark:text-neutral-300 hover:border-brand-orange hover:text-brand-orange dark:hover:border-brand-orange dark:hover:text-brand-orange transition-colors cursor-pointer"
|
||||
onClick={() => {
|
||||
setShowMore(false)
|
||||
setOpenDim(dim)
|
||||
}}
|
||||
className="w-full text-left px-4 py-2 text-sm text-neutral-700 dark:text-neutral-300 hover:bg-neutral-50 dark:hover:bg-neutral-800 transition-colors cursor-pointer"
|
||||
>
|
||||
{DIMENSION_LABELS[dim]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-5">
|
||||
{/* Selected dimension header */}
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => { setDimension(''); setOperator('is'); setValue('') }}
|
||||
className="text-neutral-400 hover:text-neutral-600 dark:hover:text-neutral-300 transition-colors cursor-pointer"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
<span className="text-sm font-semibold text-neutral-900 dark:text-white">
|
||||
{DIMENSION_LABELS[dimension]}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Operator selection */}
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{OPERATORS.map(op => (
|
||||
<button
|
||||
key={op}
|
||||
onClick={() => setOperator(op)}
|
||||
className={`px-3 py-1.5 text-xs font-medium rounded-lg transition-colors cursor-pointer ${
|
||||
operator === op
|
||||
? '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'
|
||||
}`}
|
||||
>
|
||||
{OPERATOR_LABELS[op]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Value input */}
|
||||
<input
|
||||
autoFocus
|
||||
value={value}
|
||||
onChange={e => setValue(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') handleSubmit() }}
|
||||
placeholder={`Enter ${DIMENSION_LABELS[dimension].toLowerCase()} value...`}
|
||||
className="w-full px-4 py-3 text-sm bg-neutral-50 dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 rounded-xl text-neutral-900 dark:text-white placeholder-neutral-400 focus:outline-none focus:ring-2 focus:ring-brand-orange/40 focus:border-brand-orange transition-colors"
|
||||
/>
|
||||
|
||||
{/* Apply */}
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={!value.trim()}
|
||||
className="w-full px-4 py-3 text-sm font-semibold bg-brand-orange text-white rounded-xl hover:bg-brand-orange/90 disabled:opacity-40 disabled:cursor-not-allowed transition-colors cursor-pointer"
|
||||
>
|
||||
Apply Filter
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
</>
|
||||
{/* Render popover for secondary dims inline here */}
|
||||
{openDim && SECONDARY_DIMS.includes(openDim) && (
|
||||
<DimensionPopover
|
||||
dimension={openDim}
|
||||
suggestions={suggestions[openDim] || []}
|
||||
onApply={onAdd}
|
||||
onClose={() => setOpenDim(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user