diff --git a/app/sites/[id]/page.tsx b/app/sites/[id]/page.tsx index 24a18e1..d5b7a94 100644 --- a/app/sites/[id]/page.tsx +++ b/app/sites/[id]/page.tsx @@ -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(() => { + 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() + const mediums = new Map() + const campNames = new Map() + 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() { {/* Dimension Filters */} -
+
+ -
{/* Advanced Chart with Integrated Stats */} diff --git a/components/dashboard/AddFilterDropdown.tsx b/components/dashboard/AddFilterDropdown.tsx index ee795ab..6599be3 100644 --- a/components/dashboard/AddFilterDropdown.tsx +++ b/components/dashboard/AddFilterDropdown.tsx @@ -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('is') - const [value, setValue] = useState('') + const [search, setSearch] = useState('') + const ref = useRef(null) + const inputRef = useRef(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 ( - <> - +
+ {/* Operator pills */} +
+ {OPERATORS.map(op => ( + + ))} +
- setIsOpen(false)} title="Add Filter"> - {!hasDimension ? ( -
- {DIMENSIONS.map(dim => ( + {/* Search input */} +
+ 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" + /> +
+ + {/* Suggestions list */} + {filtered.length > 0 && ( +
+ {filtered.map(s => ( + + ))} +
+ )} + + {/* Custom value apply when no matches */} + {search.trim() && filtered.length === 0 && ( +
+ +
+ )} +
+ ) +} + +export default function AddFilterDropdown({ onAdd, suggestions = {} }: AddFilterDropdownProps) { + const [openDim, setOpenDim] = useState(null) + const [showMore, setShowMore] = useState(false) + const moreRef = useRef(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 ( +
+ + {isOpen && ( + setOpenDim(null)} + /> + )} +
+ ) + } + + return ( +
+ {PRIMARY_DIMS.map(renderChip)} + + {/* More dropdown for secondary dimensions */} +
+ + {showMore && ( +
+ {SECONDARY_DIMS.map(dim => ( ))}
- ) : ( -
- {/* Selected dimension header */} -
- - - {DIMENSION_LABELS[dimension]} - -
- - {/* Operator selection */} -
- {OPERATORS.map(op => ( - - ))} -
- - {/* Value input */} - 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 */} - -
)} - - + {/* Render popover for secondary dims inline here */} + {openDim && SECONDARY_DIMS.includes(openDim) && ( + setOpenDim(null)} + /> + )} +
+
) }