ahoy_analytics 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/MIT-LICENSE +20 -0
- data/README.md +163 -0
- data/Rakefile +6 -0
- data/app/assets/ahoy_analytics/build/assets/Combination-BpSXUjp9.js +41 -0
- data/app/assets/ahoy_analytics/build/assets/analytics-5KyfCxh6.css +1 -0
- data/app/assets/ahoy_analytics/build/assets/analytics-dashboard-uOXx8zYZ.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/analytics-layout-ClAft5OU.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/analytics-tracker-B3f8P98z.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/analytics-ui-DMSkNqd6.js +90 -0
- data/app/assets/ahoy_analytics/build/assets/behaviors-panel-ChNGYbdH.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/button-JVCrlR4s.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/cable-DO-7y1-E.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/createLucideIcon-BGzacY2v.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/date-range-dialog-DWDp3cLG.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/details-button-NqKfSGEG.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/devices-panel-cXvlmNBY.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/dialog-path-BBPNlB4Z.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/dropdown-menu-Adj3O5fh.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/filter-dialog-BN-rf4lp.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/index-B1K1NTKT.js +3 -0
- data/app/assets/ahoy_analytics/build/assets/index-BcHeb-Rh.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/index-DzpzLoG4.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/index-vX97OY1J.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/input-e4v_v0kE.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/jsx-runtime-u17CrQMm.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/last-load-context-De5uA95L.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/list-table-ChHEzzF9.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/live-Cp2MHECh.js +2 -0
- data/app/assets/ahoy_analytics/build/assets/locations-panel-BaISRmaQ.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/mercator-BnxX5RzL.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/pages-panel-Bh25L8mP.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/panel-tabs-B2kvGFJx.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/query-context-B-PgE00D.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/remote-details-dialog-DDTcKaM5.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/show-CCRicksg.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/simple-tabs-D6G6Bs0k.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/site-context-BNteYRlR.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/sources-panel-DyB21hxD.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/top-bar-FSiLBjq6.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/top-stats-context-DU15P9jS.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/use-debounce-VBpXQRL8.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/user-context-DbYteluY.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/visitor-globe-BWLDihid.js +4789 -0
- data/app/assets/ahoy_analytics/build/assets/visitor-graph-uKXjLvcu.js +1 -0
- data/app/assets/ahoy_analytics/images/icon/browser/brave.svg +1 -0
- data/app/assets/ahoy_analytics/images/icon/browser/chrome.svg +1 -0
- data/app/assets/ahoy_analytics/images/icon/browser/chromium.svg +1 -0
- data/app/assets/ahoy_analytics/images/icon/browser/duckduckgo.svg +2151 -0
- data/app/assets/ahoy_analytics/images/icon/browser/edge.svg +1 -0
- data/app/assets/ahoy_analytics/images/icon/browser/fallback.svg +5 -0
- data/app/assets/ahoy_analytics/images/icon/browser/firefox.svg +1 -0
- data/app/assets/ahoy_analytics/images/icon/browser/opera.svg +1 -0
- data/app/assets/ahoy_analytics/images/icon/browser/safari.png +0 -0
- data/app/assets/ahoy_analytics/images/icon/browser/samsung-internet.svg +1 -0
- data/app/assets/ahoy_analytics/images/icon/browser/uc.svg +1 -0
- data/app/assets/ahoy_analytics/images/icon/browser/vivaldi.svg +1 -0
- data/app/assets/ahoy_analytics/images/icon/browser/yandex.png +0 -0
- data/app/assets/ahoy_analytics/images/icon/os/android.png +0 -0
- data/app/assets/ahoy_analytics/images/icon/os/chrome_os.png +0 -0
- data/app/assets/ahoy_analytics/images/icon/os/fallback.svg +5 -0
- data/app/assets/ahoy_analytics/images/icon/os/fedora.png +0 -0
- data/app/assets/ahoy_analytics/images/icon/os/freebsd.png +0 -0
- data/app/assets/ahoy_analytics/images/icon/os/gnu_linux.png +0 -0
- data/app/assets/ahoy_analytics/images/icon/os/ios.png +0 -0
- data/app/assets/ahoy_analytics/images/icon/os/ipad_os.png +0 -0
- data/app/assets/ahoy_analytics/images/icon/os/mac.png +0 -0
- data/app/assets/ahoy_analytics/images/icon/os/ubuntu.png +0 -0
- data/app/assets/ahoy_analytics/images/icon/os/windows.png +0 -0
- data/app/assets/stylesheets/ahoy_analytics/application.css +15 -0
- data/app/channels/ahoy_analytics/analytics_channel.rb +9 -0
- data/app/controllers/ahoy_analytics/analytics_controller.rb +9 -0
- data/app/controllers/ahoy_analytics/application_controller.rb +8 -0
- data/app/controllers/ahoy_analytics/assets_controller.rb +46 -0
- data/app/controllers/ahoy_analytics/base_controller.rb +285 -0
- data/app/controllers/ahoy_analytics/behaviors_controller.rb +14 -0
- data/app/controllers/ahoy_analytics/devices_controller.rb +14 -0
- data/app/controllers/ahoy_analytics/export_controller.rb +12 -0
- data/app/controllers/ahoy_analytics/live_controller.rb +9 -0
- data/app/controllers/ahoy_analytics/locations_controller.rb +14 -0
- data/app/controllers/ahoy_analytics/main_graph_controller.rb +10 -0
- data/app/controllers/ahoy_analytics/pages_controller.rb +14 -0
- data/app/controllers/ahoy_analytics/referrers_controller.rb +34 -0
- data/app/controllers/ahoy_analytics/search_terms_controller.rb +47 -0
- data/app/controllers/ahoy_analytics/sources_controller.rb +14 -0
- data/app/controllers/ahoy_analytics/top_stats_controller.rb +13 -0
- data/app/controllers/concerns/ahoy_analytics/set_current_request.rb +17 -0
- data/app/frontend/components/analytics/hex-highlights.tsx +165 -0
- data/app/frontend/components/analytics/hex-land-layer.tsx +61 -0
- data/app/frontend/components/analytics/metric-card.tsx +138 -0
- data/app/frontend/components/analytics/sessions-by-location.tsx +62 -0
- data/app/frontend/components/analytics/visitor-globe.tsx +424 -0
- data/app/frontend/components/ui/accordion.tsx +64 -0
- data/app/frontend/components/ui/alert.tsx +66 -0
- data/app/frontend/components/ui/avatar.tsx +53 -0
- data/app/frontend/components/ui/badge.tsx +46 -0
- data/app/frontend/components/ui/button.tsx +62 -0
- data/app/frontend/components/ui/calendar.tsx +212 -0
- data/app/frontend/components/ui/card.tsx +91 -0
- data/app/frontend/components/ui/checkbox.tsx +32 -0
- data/app/frontend/components/ui/dropdown-menu.tsx +255 -0
- data/app/frontend/components/ui/input.tsx +21 -0
- data/app/frontend/components/ui/label.tsx +22 -0
- data/app/frontend/components/ui/popover.tsx +46 -0
- data/app/frontend/components/ui/select.tsx +183 -0
- data/app/frontend/components/ui/separator.tsx +26 -0
- data/app/frontend/components/ui/sheet.tsx +139 -0
- data/app/frontend/components/ui/sidebar.tsx +726 -0
- data/app/frontend/components/ui/skeleton.tsx +13 -0
- data/app/frontend/components/ui/sonner.tsx +33 -0
- data/app/frontend/components/ui/tooltip.tsx +59 -0
- data/app/frontend/data/countries-110m.json +1 -0
- data/app/frontend/data/globe-data.json +1 -0
- data/app/frontend/entrypoints/analytics-tracker.ts +680 -0
- data/app/frontend/entrypoints/analytics-ui.tsx +26 -0
- data/app/frontend/entrypoints/analytics.css +77 -0
- data/app/frontend/layouts/analytics-layout.tsx +28 -0
- data/app/frontend/lib/cable.ts +13 -0
- data/app/frontend/lib/geocode.ts +65 -0
- data/app/frontend/lib/utils.ts +6 -0
- data/app/frontend/pages/admin/analytics/api.ts +221 -0
- data/app/frontend/pages/admin/analytics/hooks/use-debounce.ts +36 -0
- data/app/frontend/pages/admin/analytics/last-load-context.tsx +29 -0
- data/app/frontend/pages/admin/analytics/lib/base-path.ts +28 -0
- data/app/frontend/pages/admin/analytics/lib/dialog-path.ts +242 -0
- data/app/frontend/pages/admin/analytics/lib/number-formatter.ts +100 -0
- data/app/frontend/pages/admin/analytics/live.tsx +608 -0
- data/app/frontend/pages/admin/analytics/query-context.tsx +61 -0
- data/app/frontend/pages/admin/analytics/show.tsx +40 -0
- data/app/frontend/pages/admin/analytics/site-context.tsx +22 -0
- data/app/frontend/pages/admin/analytics/top-stats-context.tsx +37 -0
- data/app/frontend/pages/admin/analytics/types.ts +161 -0
- data/app/frontend/pages/admin/analytics/ui/analytics-dashboard.tsx +60 -0
- data/app/frontend/pages/admin/analytics/ui/behaviors-panel.tsx +456 -0
- data/app/frontend/pages/admin/analytics/ui/date-range-dialog.tsx +173 -0
- data/app/frontend/pages/admin/analytics/ui/details-button.tsx +33 -0
- data/app/frontend/pages/admin/analytics/ui/devices-panel.tsx +474 -0
- data/app/frontend/pages/admin/analytics/ui/filter-dialog.tsx +558 -0
- data/app/frontend/pages/admin/analytics/ui/list-table.tsx +346 -0
- data/app/frontend/pages/admin/analytics/ui/locations-panel.tsx +566 -0
- data/app/frontend/pages/admin/analytics/ui/pages-panel.tsx +207 -0
- data/app/frontend/pages/admin/analytics/ui/panel-tabs.tsx +65 -0
- data/app/frontend/pages/admin/analytics/ui/remote-details-dialog.tsx +356 -0
- data/app/frontend/pages/admin/analytics/ui/simple-tabs.tsx +54 -0
- data/app/frontend/pages/admin/analytics/ui/sources-panel.tsx +771 -0
- data/app/frontend/pages/admin/analytics/ui/top-bar.tsx +793 -0
- data/app/frontend/pages/admin/analytics/ui/visitor-graph.tsx +891 -0
- data/app/frontend/pages/admin/analytics/user-context.tsx +22 -0
- data/app/frontend/styles/shared.css +156 -0
- data/app/helpers/ahoy_analytics/application_helper.rb +96 -0
- data/app/jobs/ahoy_analytics/application_job.rb +4 -0
- data/app/jobs/ahoy_analytics/update_job.rb +12 -0
- data/app/mailers/ahoy_analytics/application_mailer.rb +6 -0
- data/app/models/ahoy/event/filters.rb +7 -0
- data/app/models/ahoy/event.rb +9 -0
- data/app/models/ahoy/visit/cache_key.rb +15 -0
- data/app/models/ahoy/visit/constants.rb +11 -0
- data/app/models/ahoy/visit/devices.rb +144 -0
- data/app/models/ahoy/visit/export.rb +24 -0
- data/app/models/ahoy/visit/filters.rb +286 -0
- data/app/models/ahoy/visit/imports.rb +36 -0
- data/app/models/ahoy/visit/locations.rb +276 -0
- data/app/models/ahoy/visit/metrics.rb +473 -0
- data/app/models/ahoy/visit/ordering.rb +110 -0
- data/app/models/ahoy/visit/pages.rb +533 -0
- data/app/models/ahoy/visit/pagination.rb +17 -0
- data/app/models/ahoy/visit/ranges.rb +227 -0
- data/app/models/ahoy/visit/series.rb +177 -0
- data/app/models/ahoy/visit/sources.rb +418 -0
- data/app/models/ahoy/visit/url_labels.rb +32 -0
- data/app/models/ahoy/visit.rb +143 -0
- data/app/models/ahoy_analytics/application_record.rb +5 -0
- data/app/models/ahoy_analytics/current.rb +8 -0
- data/app/models/ahoy_analytics/funnel.rb +16 -0
- data/app/models/ahoy_analytics/imported_entry_page.rb +5 -0
- data/app/models/ahoy_analytics/imported_exit_page.rb +5 -0
- data/app/models/ahoy_analytics/imported_page.rb +5 -0
- data/app/models/ahoy_analytics/live_stats.rb +152 -0
- data/app/models/ahoy_analytics/setting.rb +19 -0
- data/app/models/analytics/source_catalog.rb +48 -0
- data/app/views/layouts/ahoy_analytics/application.html.erb +15 -0
- data/config/routes.rb +21 -0
- data/config/vite.json +22 -0
- data/db/migrate/20251006104056_create_ahoy_visits_and_events.rb +62 -0
- data/db/migrate/20251006105012_add_analytics_fields_to_ahoy_visits.rb +11 -0
- data/db/migrate/20251012090000_create_analytics_funnels_and_imports.rb +52 -0
- data/db/migrate/20251013021500_add_analytics_indexes.rb +14 -0
- data/lib/ahoy_analytics/ahoy_store.rb +429 -0
- data/lib/ahoy_analytics/asset_manifest.rb +56 -0
- data/lib/ahoy_analytics/device_bucket.rb +39 -0
- data/lib/ahoy_analytics/engine.rb +55 -0
- data/lib/ahoy_analytics/maxmind_geo.rb +77 -0
- data/lib/ahoy_analytics/version.rb +3 -0
- data/lib/ahoy_analytics.rb +52 -0
- data/lib/generators/ahoy_analytics/install/install_generator.rb +111 -0
- data/lib/generators/ahoy_analytics/install/templates/initializer.rb +28 -0
- data/lib/tasks/ahoy_analytics_tasks.rake +4 -0
- metadata +352 -0
|
@@ -0,0 +1,456 @@
|
|
|
1
|
+
import { useEffect, useMemo, useState } from 'react'
|
|
2
|
+
|
|
3
|
+
import { fetchBehaviors } from '../api'
|
|
4
|
+
import { useQueryContext } from '../query-context'
|
|
5
|
+
import type { BehaviorsPayload, ListMetricKey, ListPayload } from '../types'
|
|
6
|
+
import { useSiteContext } from '../site-context'
|
|
7
|
+
import { MetricTable } from './list-table'
|
|
8
|
+
import { PanelTab, PanelTabDropdown, PanelTabs } from './panel-tabs'
|
|
9
|
+
import RemoteDetailsDialog from './remote-details-dialog'
|
|
10
|
+
import { parseDialogFromPath, buildDialogPath, baseAnalyticsPath } from '../lib/dialog-path'
|
|
11
|
+
import { analyticsPath } from '../lib/base-path'
|
|
12
|
+
import DetailsButton from './details-button'
|
|
13
|
+
import { Input } from '@/components/ui/input'
|
|
14
|
+
import {
|
|
15
|
+
DropdownMenu,
|
|
16
|
+
DropdownMenuContent,
|
|
17
|
+
DropdownMenuItem,
|
|
18
|
+
DropdownMenuTrigger
|
|
19
|
+
} from '@/components/ui/dropdown-menu'
|
|
20
|
+
|
|
21
|
+
const BEHAVIOR_TABS: Array<{ value: string; label: string }> = [
|
|
22
|
+
{ value: 'conversions', label: 'Goals' },
|
|
23
|
+
{ value: 'props', label: 'Properties' },
|
|
24
|
+
{ value: 'funnels', label: 'Funnels' }
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
const STORAGE_PREFIX = 'admin.analytics.behaviors'
|
|
28
|
+
|
|
29
|
+
type BehaviorsPanelProps = {
|
|
30
|
+
initialData: BehaviorsPayload
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export default function BehaviorsPanel({ initialData }: BehaviorsPanelProps) {
|
|
34
|
+
const { query, updateQuery } = useQueryContext()
|
|
35
|
+
const site = useSiteContext()
|
|
36
|
+
|
|
37
|
+
const behaviourTabs = useMemo(
|
|
38
|
+
() => (site.hasGoals ? BEHAVIOR_TABS : BEHAVIOR_TABS.filter((tab) => tab.value !== 'conversions')),
|
|
39
|
+
[site.hasGoals]
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
const defaultMode = behaviourTabs[0]?.value ?? 'props'
|
|
43
|
+
|
|
44
|
+
const [mode, setMode] = useState(() => {
|
|
45
|
+
if (typeof window === 'undefined') {
|
|
46
|
+
return defaultMode
|
|
47
|
+
}
|
|
48
|
+
const stored = localStorage.getItem(`${STORAGE_PREFIX}.${site.domain}`)
|
|
49
|
+
return stored && behaviourTabs.some((tab) => tab.value === stored) ? stored : defaultMode
|
|
50
|
+
})
|
|
51
|
+
const [data, setData] = useState<BehaviorsPayload>(initialData)
|
|
52
|
+
const [loading, setLoading] = useState(false)
|
|
53
|
+
const [selectedFunnel, setSelectedFunnel] = useState(() =>
|
|
54
|
+
'funnels' in initialData && initialData.funnels.length > 0 ? initialData.funnels[0] : undefined
|
|
55
|
+
)
|
|
56
|
+
const [selectedProperty, setSelectedProperty] = useState<string | null>(null)
|
|
57
|
+
const [detailsOpen, setDetailsOpen] = useState(false)
|
|
58
|
+
|
|
59
|
+
const setAndStoreMode = (value: string) => {
|
|
60
|
+
setMode(value)
|
|
61
|
+
if (typeof window !== 'undefined') {
|
|
62
|
+
localStorage.setItem(`${STORAGE_PREFIX}.${site.domain}`, value)
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
useEffect(() => {
|
|
67
|
+
const controller = new AbortController()
|
|
68
|
+
setLoading(true)
|
|
69
|
+
fetchBehaviors(query, { mode, funnel: selectedFunnel }, controller.signal)
|
|
70
|
+
.then((value) => {
|
|
71
|
+
setData(value)
|
|
72
|
+
if ('funnels' in value && !value.funnels.includes(selectedFunnel ?? '')) {
|
|
73
|
+
setSelectedFunnel(value.funnels[0])
|
|
74
|
+
}
|
|
75
|
+
})
|
|
76
|
+
.catch((error) => {
|
|
77
|
+
if (error.name !== 'AbortError') console.error(error)
|
|
78
|
+
})
|
|
79
|
+
.finally(() => setLoading(false))
|
|
80
|
+
|
|
81
|
+
return () => controller.abort()
|
|
82
|
+
}, [mode, query, selectedFunnel])
|
|
83
|
+
|
|
84
|
+
// Deep-link: open Behaviors dialog if path is /_/behaviors
|
|
85
|
+
useEffect(() => {
|
|
86
|
+
const parsed = parseDialogFromPath(window.location.pathname)
|
|
87
|
+
if (parsed.type === 'segment' && parsed.segment === 'behaviors') {
|
|
88
|
+
setDetailsOpen(true)
|
|
89
|
+
}
|
|
90
|
+
}, [])
|
|
91
|
+
|
|
92
|
+
const listPayload: ListPayload | null = useMemo(() => {
|
|
93
|
+
if ('list' in data) {
|
|
94
|
+
return data.list
|
|
95
|
+
}
|
|
96
|
+
if (!('funnels' in data) && 'results' in data) {
|
|
97
|
+
return data as ListPayload
|
|
98
|
+
}
|
|
99
|
+
return null
|
|
100
|
+
}, [data])
|
|
101
|
+
|
|
102
|
+
const propertyOptions = useMemo(() => {
|
|
103
|
+
if (mode !== 'props' || !listPayload) {
|
|
104
|
+
return []
|
|
105
|
+
}
|
|
106
|
+
return Array.from(new Set(listPayload.results.map((item) => String(item.name ?? ''))))
|
|
107
|
+
}, [listPayload, mode])
|
|
108
|
+
|
|
109
|
+
useEffect(() => {
|
|
110
|
+
if (mode !== 'props') {
|
|
111
|
+
return
|
|
112
|
+
}
|
|
113
|
+
if (propertyOptions.length === 0) {
|
|
114
|
+
setSelectedProperty(null)
|
|
115
|
+
return
|
|
116
|
+
}
|
|
117
|
+
setSelectedProperty((current) => (current && propertyOptions.includes(current) ? current : propertyOptions[0]))
|
|
118
|
+
}, [mode, propertyOptions])
|
|
119
|
+
|
|
120
|
+
const tablePayload = useMemo(() => {
|
|
121
|
+
if (!listPayload) return null
|
|
122
|
+
if (mode === 'props') {
|
|
123
|
+
const target = selectedProperty
|
|
124
|
+
const results = listPayload.results
|
|
125
|
+
.filter((item) => (target ? String(item.name) === target : true))
|
|
126
|
+
.map((item) => ({
|
|
127
|
+
...item,
|
|
128
|
+
name: String((item as Record<string, unknown>).value ?? item.name)
|
|
129
|
+
}))
|
|
130
|
+
return {
|
|
131
|
+
...listPayload,
|
|
132
|
+
results
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return listPayload
|
|
136
|
+
}, [listPayload, mode, selectedProperty])
|
|
137
|
+
|
|
138
|
+
// Limit card view to top 9 by first metric; Details uses full tablePayload
|
|
139
|
+
const limitedTablePayload = useMemo((): ListPayload | null => {
|
|
140
|
+
if (!tablePayload) return null
|
|
141
|
+
const isConversions = mode === 'conversions'
|
|
142
|
+
const metricKey = isConversions ? 'uniques' : (tablePayload.metrics[0] ?? 'visitors')
|
|
143
|
+
const sorted = [...tablePayload.results].sort((a, b) => {
|
|
144
|
+
const av = Number((a as Record<string, unknown>)[metricKey] ?? 0)
|
|
145
|
+
const bv = Number((b as Record<string, unknown>)[metricKey] ?? 0)
|
|
146
|
+
if (av === bv) return String(a.name).localeCompare(String(b.name))
|
|
147
|
+
return bv - av
|
|
148
|
+
})
|
|
149
|
+
const sliced = sorted.slice(0, 9)
|
|
150
|
+
return {
|
|
151
|
+
...tablePayload,
|
|
152
|
+
metrics: (isConversions ? ['uniques'] : tablePayload.metrics) as ListMetricKey[],
|
|
153
|
+
results: sliced,
|
|
154
|
+
meta: { ...tablePayload.meta, hasMore: tablePayload.results.length > 9 }
|
|
155
|
+
}
|
|
156
|
+
}, [tablePayload, mode])
|
|
157
|
+
|
|
158
|
+
const activeTitle = useMemo(() => {
|
|
159
|
+
switch (mode) {
|
|
160
|
+
case 'props':
|
|
161
|
+
return site.propsAvailable ? 'Custom Properties' : 'Properties'
|
|
162
|
+
case 'funnels':
|
|
163
|
+
return 'Funnels'
|
|
164
|
+
case 'conversions':
|
|
165
|
+
default:
|
|
166
|
+
return site.hasGoals ? 'Goal Conversions' : 'Behaviors'
|
|
167
|
+
}
|
|
168
|
+
}, [mode, site.hasGoals, site.propsAvailable])
|
|
169
|
+
|
|
170
|
+
const firstColumnLabel = useMemo(() => {
|
|
171
|
+
switch (mode) {
|
|
172
|
+
case 'props':
|
|
173
|
+
return 'Property'
|
|
174
|
+
case 'funnels':
|
|
175
|
+
return 'Step'
|
|
176
|
+
default:
|
|
177
|
+
return 'Goal'
|
|
178
|
+
}
|
|
179
|
+
}, [mode])
|
|
180
|
+
|
|
181
|
+
const availableFunnels = useMemo(() => ('funnels' in data ? data.funnels : []), [data])
|
|
182
|
+
|
|
183
|
+
return (
|
|
184
|
+
<section className="flex flex-col gap-5 rounded-xl border border-border bg-card p-5 shadow-[0_12px_26px_rgba(7,9,16,0.32)]">
|
|
185
|
+
<header className="flex flex-wrap items-center justify-between gap-3">
|
|
186
|
+
<h2 className="text-lg/6 font-semibold text-foreground/80">{activeTitle}</h2>
|
|
187
|
+
<PanelTabs>
|
|
188
|
+
{behaviourTabs
|
|
189
|
+
.filter((tab) => tab.value !== 'funnels')
|
|
190
|
+
.map((tab) => (
|
|
191
|
+
<PanelTab
|
|
192
|
+
key={tab.value}
|
|
193
|
+
active={mode === tab.value}
|
|
194
|
+
onClick={() => setAndStoreMode(tab.value)}
|
|
195
|
+
>
|
|
196
|
+
{tab.label}
|
|
197
|
+
</PanelTab>
|
|
198
|
+
))}
|
|
199
|
+
{availableFunnels.length > 0 ? (
|
|
200
|
+
<PanelTabDropdown
|
|
201
|
+
active={mode === 'funnels'}
|
|
202
|
+
label="Funnels"
|
|
203
|
+
options={availableFunnels.map((funnel) => ({ value: funnel, label: funnel }))}
|
|
204
|
+
onSelect={(value) => {
|
|
205
|
+
setSelectedFunnel(value)
|
|
206
|
+
setAndStoreMode('funnels')
|
|
207
|
+
}}
|
|
208
|
+
/>
|
|
209
|
+
) : (
|
|
210
|
+
<PanelTab active={mode === 'funnels'} onClick={() => setAndStoreMode('funnels')}>
|
|
211
|
+
Funnels
|
|
212
|
+
</PanelTab>
|
|
213
|
+
)}
|
|
214
|
+
</PanelTabs>
|
|
215
|
+
</header>
|
|
216
|
+
{!site.hasGoals ? (
|
|
217
|
+
<p className="text-sm text-muted-foreground">
|
|
218
|
+
Goal tracking configuration is coming soon. Explore properties or funnels in the meantime.
|
|
219
|
+
</p>
|
|
220
|
+
) : null}
|
|
221
|
+
|
|
222
|
+
{loading ? (
|
|
223
|
+
<div className="flex h-48 items-center justify-center text-sm text-muted-foreground">Loading…</div>
|
|
224
|
+
) : mode === 'funnels' && 'funnels' in data ? (
|
|
225
|
+
<FunnelSteps data={data} onSelectFunnel={setSelectedFunnel} selectedFunnel={selectedFunnel} />
|
|
226
|
+
) : tablePayload ? (
|
|
227
|
+
<>
|
|
228
|
+
{mode === 'props' && propertyOptions.length > 0 ? (
|
|
229
|
+
<div className="flex justify-end">
|
|
230
|
+
<PropertyCombobox
|
|
231
|
+
value={selectedProperty ?? undefined}
|
|
232
|
+
options={propertyOptions}
|
|
233
|
+
onChange={setSelectedProperty}
|
|
234
|
+
/>
|
|
235
|
+
</div>
|
|
236
|
+
) : null}
|
|
237
|
+
{mode === 'props' && selectedProperty ? (
|
|
238
|
+
<p className="text-xs font-semibold uppercase text-muted-foreground">{selectedProperty}</p>
|
|
239
|
+
) : null}
|
|
240
|
+
<MetricTable
|
|
241
|
+
data={limitedTablePayload ?? tablePayload}
|
|
242
|
+
highlightedMetric={
|
|
243
|
+
mode === 'conversions'
|
|
244
|
+
? 'uniques'
|
|
245
|
+
: (tablePayload.metrics.includes('conversionRate') ? 'conversionRate' : tablePayload.metrics[0])
|
|
246
|
+
}
|
|
247
|
+
onRowClick={(item) => {
|
|
248
|
+
if (mode === 'props') {
|
|
249
|
+
updateQuery((current) => ({
|
|
250
|
+
...current,
|
|
251
|
+
filters: { ...current.filters, prop: String(item.name) }
|
|
252
|
+
}))
|
|
253
|
+
} else {
|
|
254
|
+
updateQuery((current) => ({
|
|
255
|
+
...current,
|
|
256
|
+
filters: { ...current.filters, goal: String(item.name) }
|
|
257
|
+
}))
|
|
258
|
+
}
|
|
259
|
+
}}
|
|
260
|
+
displayBars={false}
|
|
261
|
+
firstColumnLabel={firstColumnLabel}
|
|
262
|
+
barColorTheme="cyan"
|
|
263
|
+
/>
|
|
264
|
+
<div className="mt-auto flex justify-center pt-3">
|
|
265
|
+
<DetailsButton onClick={() => {
|
|
266
|
+
setDetailsOpen(true)
|
|
267
|
+
try {
|
|
268
|
+
const sp = new URLSearchParams(window.location.search)
|
|
269
|
+
sp.delete('dialog'); sp.delete('mode')
|
|
270
|
+
window.history.pushState({}, '', buildDialogPath('behaviors', sp.toString()))
|
|
271
|
+
} catch {}
|
|
272
|
+
}}>Details</DetailsButton>
|
|
273
|
+
</div>
|
|
274
|
+
</>
|
|
275
|
+
) : (
|
|
276
|
+
<p className="text-sm text-muted-foreground">No data available</p>
|
|
277
|
+
)}
|
|
278
|
+
|
|
279
|
+
{tablePayload ? (
|
|
280
|
+
<RemoteDetailsDialog
|
|
281
|
+
open={detailsOpen}
|
|
282
|
+
onOpenChange={(open) => {
|
|
283
|
+
setDetailsOpen(open)
|
|
284
|
+
try {
|
|
285
|
+
const sp = new URLSearchParams(window.location.search)
|
|
286
|
+
sp.delete('dialog'); sp.delete('mode')
|
|
287
|
+
const qs = sp.toString()
|
|
288
|
+
if (open) {
|
|
289
|
+
window.history.pushState({}, '', buildDialogPath('behaviors', qs))
|
|
290
|
+
} else {
|
|
291
|
+
window.history.pushState({}, '', baseAnalyticsPath(qs))
|
|
292
|
+
}
|
|
293
|
+
} catch {}
|
|
294
|
+
}}
|
|
295
|
+
title={activeTitle}
|
|
296
|
+
endpoint={analyticsPath('behaviors')}
|
|
297
|
+
extras={{ mode, funnel: selectedFunnel }}
|
|
298
|
+
firstColumnLabel={firstColumnLabel}
|
|
299
|
+
initialSearch={mode === 'props' ? (selectedProperty ?? '') : ''}
|
|
300
|
+
defaultSortKey={tablePayload.metrics.includes('conversionRate') ? 'conversionRate' as ListMetricKey : (tablePayload.metrics[0] as ListMetricKey)}
|
|
301
|
+
onRowClick={(item) => {
|
|
302
|
+
if (mode === 'props') {
|
|
303
|
+
updateQuery((current) => ({
|
|
304
|
+
...current,
|
|
305
|
+
filters: { ...current.filters, prop: String(item.name) }
|
|
306
|
+
}))
|
|
307
|
+
} else {
|
|
308
|
+
updateQuery((current) => ({
|
|
309
|
+
...current,
|
|
310
|
+
filters: { ...current.filters, goal: String(item.name) }
|
|
311
|
+
}))
|
|
312
|
+
}
|
|
313
|
+
setDetailsOpen(false)
|
|
314
|
+
}}
|
|
315
|
+
/>
|
|
316
|
+
) : null}
|
|
317
|
+
</section>
|
|
318
|
+
)
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
type FunnelStepsProps = {
|
|
322
|
+
data: Extract<BehaviorsPayload, { funnels: string[]; active: { steps: unknown[] } }>
|
|
323
|
+
selectedFunnel?: string
|
|
324
|
+
onSelectFunnel: (name: string) => void
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function FunnelSteps({ data, selectedFunnel, onSelectFunnel }: FunnelStepsProps) {
|
|
328
|
+
const funnel = data.active
|
|
329
|
+
if (!funnel) return null
|
|
330
|
+
|
|
331
|
+
const steps = funnel.steps
|
|
332
|
+
const maxVisitors = Math.max(...steps.map((step) => step.visitors), 1)
|
|
333
|
+
const overallRate = steps[steps.length - 1]?.conversionRate ?? 0
|
|
334
|
+
|
|
335
|
+
return (
|
|
336
|
+
<div className="space-y-6">
|
|
337
|
+
<div className="space-y-1">
|
|
338
|
+
<div className="flex flex-wrap items-center justify-between gap-2">
|
|
339
|
+
<div>
|
|
340
|
+
<p className="text-lg font-semibold text-foreground">{funnel.name}</p>
|
|
341
|
+
<p className="text-sm text-muted-foreground">
|
|
342
|
+
{steps.length}-step funnel • {Math.round(overallRate * 1000) / 10}% conversion rate
|
|
343
|
+
</p>
|
|
344
|
+
</div>
|
|
345
|
+
{!onSelectFunnel || data.funnels.length <= 1
|
|
346
|
+
? null
|
|
347
|
+
: data.funnels.map((name) => (
|
|
348
|
+
<button
|
|
349
|
+
key={name}
|
|
350
|
+
type="button"
|
|
351
|
+
onClick={() => onSelectFunnel(name)}
|
|
352
|
+
className={`rounded-full px-3 py-1 text-xs font-medium transition ${
|
|
353
|
+
name === (selectedFunnel ?? funnel.name)
|
|
354
|
+
? 'bg-primary text-primary-foreground'
|
|
355
|
+
: 'bg-muted text-muted-foreground'
|
|
356
|
+
}`}
|
|
357
|
+
>
|
|
358
|
+
{name}
|
|
359
|
+
</button>
|
|
360
|
+
))}
|
|
361
|
+
</div>
|
|
362
|
+
</div>
|
|
363
|
+
|
|
364
|
+
<div className="flex items-end gap-6 overflow-x-auto pb-4">
|
|
365
|
+
{steps.map((step) => {
|
|
366
|
+
const heightPercent = Math.max((step.visitors / maxVisitors) * 100, 12)
|
|
367
|
+
return (
|
|
368
|
+
<div key={step.name} className="flex w-20 flex-col items-center gap-3 text-center">
|
|
369
|
+
<div className="relative flex h-48 w-full items-end justify-center">
|
|
370
|
+
<div className="relative h-full w-12 rounded-xs bg-primary/15">
|
|
371
|
+
<div
|
|
372
|
+
className="absolute bottom-0 left-0 right-0 rounded-xs bg-primary"
|
|
373
|
+
style={{ height: `${heightPercent}%` }}
|
|
374
|
+
/>
|
|
375
|
+
</div>
|
|
376
|
+
<div className="absolute -top-12 w-24 rounded-xs bg-slate-900 px-2 py-1 text-[11px] font-semibold text-white shadow-xs">
|
|
377
|
+
{Math.round(step.conversionRate * 1000) / 10}%
|
|
378
|
+
<span className="mt-1 block text-[10px] font-normal text-slate-200">
|
|
379
|
+
{new Intl.NumberFormat('en-US').format(step.visitors)} visitors
|
|
380
|
+
</span>
|
|
381
|
+
</div>
|
|
382
|
+
</div>
|
|
383
|
+
<p className="text-sm font-medium text-foreground">{step.name}</p>
|
|
384
|
+
</div>
|
|
385
|
+
)
|
|
386
|
+
})}
|
|
387
|
+
</div>
|
|
388
|
+
</div>
|
|
389
|
+
)
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function PropertyCombobox({
|
|
393
|
+
value,
|
|
394
|
+
options,
|
|
395
|
+
onChange
|
|
396
|
+
}: {
|
|
397
|
+
value?: string
|
|
398
|
+
options: string[]
|
|
399
|
+
onChange: (next: string) => void
|
|
400
|
+
}) {
|
|
401
|
+
const [open, setOpen] = useState(false)
|
|
402
|
+
const [search, setSearch] = useState('')
|
|
403
|
+
|
|
404
|
+
const filtered = useMemo(() => {
|
|
405
|
+
if (!search) return options
|
|
406
|
+
return options.filter((option) => option.toLowerCase().includes(search.toLowerCase()))
|
|
407
|
+
}, [options, search])
|
|
408
|
+
|
|
409
|
+
const label = value ?? 'Select property'
|
|
410
|
+
|
|
411
|
+
return (
|
|
412
|
+
<DropdownMenu
|
|
413
|
+
open={open}
|
|
414
|
+
onOpenChange={(next) => {
|
|
415
|
+
setOpen(next)
|
|
416
|
+
if (!next) setSearch('')
|
|
417
|
+
}}
|
|
418
|
+
>
|
|
419
|
+
<DropdownMenuTrigger asChild>
|
|
420
|
+
<button
|
|
421
|
+
type="button"
|
|
422
|
+
className="inline-flex h-9 items-center justify-between gap-2 rounded-xs border px-3 text-sm font-medium text-foreground shadow-xs hover:bg-muted"
|
|
423
|
+
>
|
|
424
|
+
<span className="max-w-[10rem] truncate">{label}</span>
|
|
425
|
+
</button>
|
|
426
|
+
</DropdownMenuTrigger>
|
|
427
|
+
<DropdownMenuContent align="end" className="w-64 p-0">
|
|
428
|
+
<div className="border-b p-2">
|
|
429
|
+
<Input
|
|
430
|
+
value={search}
|
|
431
|
+
onChange={(event) => setSearch(event.target.value)}
|
|
432
|
+
autoFocus
|
|
433
|
+
placeholder="Search properties"
|
|
434
|
+
/>
|
|
435
|
+
</div>
|
|
436
|
+
<div className="max-h-56 overflow-y-auto py-1">
|
|
437
|
+
{filtered.map((option) => (
|
|
438
|
+
<DropdownMenuItem
|
|
439
|
+
key={option}
|
|
440
|
+
onClick={() => {
|
|
441
|
+
onChange(option)
|
|
442
|
+
setOpen(false)
|
|
443
|
+
}}
|
|
444
|
+
className="cursor-pointer"
|
|
445
|
+
>
|
|
446
|
+
{option}
|
|
447
|
+
</DropdownMenuItem>
|
|
448
|
+
))}
|
|
449
|
+
{filtered.length === 0 ? (
|
|
450
|
+
<div className="px-3 py-2 text-sm text-muted-foreground">No matches</div>
|
|
451
|
+
) : null}
|
|
452
|
+
</div>
|
|
453
|
+
</DropdownMenuContent>
|
|
454
|
+
</DropdownMenu>
|
|
455
|
+
)
|
|
456
|
+
}
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
2
|
+
import { Calendar } from '@/components/ui/calendar'
|
|
3
|
+
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
|
4
|
+
import type { DateRange } from 'react-day-picker'
|
|
5
|
+
|
|
6
|
+
interface DateRangePickerProps {
|
|
7
|
+
// Anchor element for positioning (hidden trigger). Kept for positioning only.
|
|
8
|
+
buttonRef: React.RefObject<HTMLButtonElement | null>
|
|
9
|
+
// Controlled open state (optional). When provided, component becomes controlled.
|
|
10
|
+
open?: boolean
|
|
11
|
+
onOpenChange?: (open: boolean) => void
|
|
12
|
+
onApply: (from: string, to: string) => void
|
|
13
|
+
// Preselect an existing range when opening
|
|
14
|
+
initialFrom?: string | null
|
|
15
|
+
initialTo?: string | null
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export default function DateRangePicker({ buttonRef, onApply, open, onOpenChange, initialFrom, initialTo }: DateRangePickerProps) {
|
|
19
|
+
const [dateRange, setDateRange] = useState<DateRange | undefined>(undefined)
|
|
20
|
+
const [hasPickedStart, setHasPickedStart] = useState(false)
|
|
21
|
+
const [isPreloadedRange, setIsPreloadedRange] = useState(false) // Track if current range is from initial props
|
|
22
|
+
const isControlled = useMemo(() => typeof open === 'boolean', [open])
|
|
23
|
+
const ignoreOutsideUntil = useRef<number>(0)
|
|
24
|
+
const forceCloseOnce = useRef(false)
|
|
25
|
+
|
|
26
|
+
const toYmd = useCallback((d: Date) => {
|
|
27
|
+
const y = d.getFullYear()
|
|
28
|
+
const m = String(d.getMonth() + 1).padStart(2, '0')
|
|
29
|
+
const day = String(d.getDate()).padStart(2, '0')
|
|
30
|
+
return `${y}-${m}-${day}`
|
|
31
|
+
}, [])
|
|
32
|
+
|
|
33
|
+
const parseYmd = useCallback((s: string) => {
|
|
34
|
+
const [y, m, d] = String(s).slice(0, 10).split('-').map((n) => Number(n))
|
|
35
|
+
return new Date(y, (m || 1) - 1, d || 1, 12) // noon local to avoid tz drift
|
|
36
|
+
}, [])
|
|
37
|
+
|
|
38
|
+
// Ensure a selection state every time the popover opens (controlled prop)
|
|
39
|
+
useEffect(() => {
|
|
40
|
+
if (open) {
|
|
41
|
+
if (initialFrom && initialTo) {
|
|
42
|
+
setDateRange({ from: parseYmd(initialFrom), to: parseYmd(initialTo) })
|
|
43
|
+
setHasPickedStart(false)
|
|
44
|
+
setIsPreloadedRange(true) // Mark this as a preloaded range
|
|
45
|
+
} else {
|
|
46
|
+
setDateRange(undefined)
|
|
47
|
+
setHasPickedStart(false)
|
|
48
|
+
setIsPreloadedRange(false)
|
|
49
|
+
}
|
|
50
|
+
ignoreOutsideUntil.current = performance.now() + 250
|
|
51
|
+
forceCloseOnce.current = false
|
|
52
|
+
}
|
|
53
|
+
}, [open, initialFrom, initialTo, parseYmd])
|
|
54
|
+
|
|
55
|
+
const setOpen = useCallback((next: boolean) => {
|
|
56
|
+
if (onOpenChange) onOpenChange(next)
|
|
57
|
+
else {
|
|
58
|
+
// Fallback for uncontrolled mode: toggle via trigger click
|
|
59
|
+
if (!next && buttonRef.current) {
|
|
60
|
+
// Close by toggling trigger if popover was opened via trigger
|
|
61
|
+
try { buttonRef.current.click() } catch (_) {}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}, [onOpenChange, buttonRef])
|
|
65
|
+
|
|
66
|
+
const requestClose = useCallback(() => {
|
|
67
|
+
forceCloseOnce.current = true
|
|
68
|
+
setOpen(false)
|
|
69
|
+
}, [setOpen])
|
|
70
|
+
|
|
71
|
+
// Reset date range when popover opens
|
|
72
|
+
const handleOpenChange = (nextOpen: boolean) => {
|
|
73
|
+
if (nextOpen) {
|
|
74
|
+
// Guard: ignore outside interactions for a short window after opening
|
|
75
|
+
ignoreOutsideUntil.current = performance.now() + 250
|
|
76
|
+
}
|
|
77
|
+
if (!nextOpen && forceCloseOnce.current) {
|
|
78
|
+
forceCloseOnce.current = false
|
|
79
|
+
}
|
|
80
|
+
// If controlled, delegate; if uncontrolled, Radix will handle it
|
|
81
|
+
if (isControlled) onOpenChange?.(nextOpen)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Use onDayClick to drive our own range logic so the previous selection doesn't influence the new start
|
|
85
|
+
const handleDayClick = useCallback((day: Date) => {
|
|
86
|
+
const clicked = new Date(day.getFullYear(), day.getMonth(), day.getDate(), 12)
|
|
87
|
+
|
|
88
|
+
// If we're looking at a preloaded range OR starting fresh, begin new selection
|
|
89
|
+
// Set both from and to to the same date to show a single-day selection
|
|
90
|
+
if (isPreloadedRange || !hasPickedStart || !dateRange?.from) {
|
|
91
|
+
setDateRange({ from: clicked, to: clicked })
|
|
92
|
+
setHasPickedStart(true)
|
|
93
|
+
setIsPreloadedRange(false)
|
|
94
|
+
return
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Second pick → normalize order and apply
|
|
98
|
+
const start = dateRange.from
|
|
99
|
+
if (clicked < start) {
|
|
100
|
+
setDateRange({ from: clicked, to: start })
|
|
101
|
+
onApply(toYmd(clicked), toYmd(start))
|
|
102
|
+
} else {
|
|
103
|
+
setDateRange({ from: start, to: clicked })
|
|
104
|
+
onApply(toYmd(start), toYmd(clicked))
|
|
105
|
+
}
|
|
106
|
+
requestAnimationFrame(() => requestClose())
|
|
107
|
+
}, [dateRange?.from, hasPickedStart, isPreloadedRange, onApply, requestClose, toYmd])
|
|
108
|
+
|
|
109
|
+
// Custom modifiers for styling - manually control what's highlighted
|
|
110
|
+
const modifiers = useMemo(() => {
|
|
111
|
+
if (!dateRange?.from) return {}
|
|
112
|
+
|
|
113
|
+
const mods: any = {
|
|
114
|
+
range_start: dateRange.from,
|
|
115
|
+
range_end: dateRange.to || dateRange.from,
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Add range_middle for dates between start and end
|
|
119
|
+
if (dateRange.from && dateRange.to && dateRange.from < dateRange.to) {
|
|
120
|
+
const middleDays: Date[] = []
|
|
121
|
+
const current = new Date(dateRange.from)
|
|
122
|
+
current.setDate(current.getDate() + 1)
|
|
123
|
+
|
|
124
|
+
while (current < dateRange.to) {
|
|
125
|
+
middleDays.push(new Date(current))
|
|
126
|
+
current.setDate(current.getDate() + 1)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (middleDays.length > 0) {
|
|
130
|
+
mods.range_middle = middleDays
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return mods
|
|
135
|
+
}, [dateRange])
|
|
136
|
+
|
|
137
|
+
return (
|
|
138
|
+
<Popover open={open} onOpenChange={handleOpenChange} modal>
|
|
139
|
+
<PopoverTrigger asChild>
|
|
140
|
+
<button
|
|
141
|
+
ref={buttonRef}
|
|
142
|
+
className="h-9 w-0 outline-none opacity-0 pointer-events-none"
|
|
143
|
+
tabIndex={-1}
|
|
144
|
+
aria-hidden="true"
|
|
145
|
+
/>
|
|
146
|
+
</PopoverTrigger>
|
|
147
|
+
<PopoverContent
|
|
148
|
+
className="w-auto p-0"
|
|
149
|
+
align="end"
|
|
150
|
+
sideOffset={8}
|
|
151
|
+
onOpenAutoFocus={(e) => e.preventDefault()}
|
|
152
|
+
onInteractOutside={(e) => {
|
|
153
|
+
// Swallow the outside event that belongs to the dropdown click
|
|
154
|
+
if (performance.now() < ignoreOutsideUntil.current) {
|
|
155
|
+
e.preventDefault()
|
|
156
|
+
return
|
|
157
|
+
}
|
|
158
|
+
}}
|
|
159
|
+
>
|
|
160
|
+
<div className="flex flex-col">
|
|
161
|
+
<Calendar
|
|
162
|
+
modifiers={modifiers}
|
|
163
|
+
onDayClick={handleDayClick}
|
|
164
|
+
numberOfMonths={1}
|
|
165
|
+
disabled={{ after: new Date() }}
|
|
166
|
+
toDate={new Date()}
|
|
167
|
+
className="border-b border-border"
|
|
168
|
+
/>
|
|
169
|
+
</div>
|
|
170
|
+
</PopoverContent>
|
|
171
|
+
</Popover>
|
|
172
|
+
)
|
|
173
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { ButtonHTMLAttributes } from 'react'
|
|
2
|
+
|
|
3
|
+
export default function DetailsButton({ className, children, ...props }: ButtonHTMLAttributes<HTMLButtonElement>) {
|
|
4
|
+
return (
|
|
5
|
+
<button
|
|
6
|
+
type="button"
|
|
7
|
+
{...props}
|
|
8
|
+
className={[
|
|
9
|
+
'inline-flex items-center gap-2 text-xs font-semibold uppercase tracking-wider text-muted-foreground transition hover:text-primary focus:outline-2 focus:outline-primary',
|
|
10
|
+
className
|
|
11
|
+
]
|
|
12
|
+
.filter(Boolean)
|
|
13
|
+
.join(' ')}
|
|
14
|
+
>
|
|
15
|
+
{/* Magnifying glass (search/inspect) for "Details" */}
|
|
16
|
+
<svg
|
|
17
|
+
aria-hidden="true"
|
|
18
|
+
className="size-4"
|
|
19
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
20
|
+
viewBox="0 0 24 24"
|
|
21
|
+
fill="none"
|
|
22
|
+
stroke="currentColor"
|
|
23
|
+
strokeWidth="2"
|
|
24
|
+
strokeLinecap="round"
|
|
25
|
+
strokeLinejoin="round"
|
|
26
|
+
>
|
|
27
|
+
<circle cx="11" cy="11" r="7" />
|
|
28
|
+
<line x1="21" y1="21" x2="16.65" y2="16.65" />
|
|
29
|
+
</svg>
|
|
30
|
+
{children ?? 'Details'}
|
|
31
|
+
</button>
|
|
32
|
+
)
|
|
33
|
+
}
|