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.
Files changed (198) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +163 -0
  4. data/Rakefile +6 -0
  5. data/app/assets/ahoy_analytics/build/assets/Combination-BpSXUjp9.js +41 -0
  6. data/app/assets/ahoy_analytics/build/assets/analytics-5KyfCxh6.css +1 -0
  7. data/app/assets/ahoy_analytics/build/assets/analytics-dashboard-uOXx8zYZ.js +1 -0
  8. data/app/assets/ahoy_analytics/build/assets/analytics-layout-ClAft5OU.js +1 -0
  9. data/app/assets/ahoy_analytics/build/assets/analytics-tracker-B3f8P98z.js +1 -0
  10. data/app/assets/ahoy_analytics/build/assets/analytics-ui-DMSkNqd6.js +90 -0
  11. data/app/assets/ahoy_analytics/build/assets/behaviors-panel-ChNGYbdH.js +1 -0
  12. data/app/assets/ahoy_analytics/build/assets/button-JVCrlR4s.js +1 -0
  13. data/app/assets/ahoy_analytics/build/assets/cable-DO-7y1-E.js +1 -0
  14. data/app/assets/ahoy_analytics/build/assets/createLucideIcon-BGzacY2v.js +1 -0
  15. data/app/assets/ahoy_analytics/build/assets/date-range-dialog-DWDp3cLG.js +1 -0
  16. data/app/assets/ahoy_analytics/build/assets/details-button-NqKfSGEG.js +1 -0
  17. data/app/assets/ahoy_analytics/build/assets/devices-panel-cXvlmNBY.js +1 -0
  18. data/app/assets/ahoy_analytics/build/assets/dialog-path-BBPNlB4Z.js +1 -0
  19. data/app/assets/ahoy_analytics/build/assets/dropdown-menu-Adj3O5fh.js +1 -0
  20. data/app/assets/ahoy_analytics/build/assets/filter-dialog-BN-rf4lp.js +1 -0
  21. data/app/assets/ahoy_analytics/build/assets/index-B1K1NTKT.js +3 -0
  22. data/app/assets/ahoy_analytics/build/assets/index-BcHeb-Rh.js +1 -0
  23. data/app/assets/ahoy_analytics/build/assets/index-DzpzLoG4.js +1 -0
  24. data/app/assets/ahoy_analytics/build/assets/index-vX97OY1J.js +1 -0
  25. data/app/assets/ahoy_analytics/build/assets/input-e4v_v0kE.js +1 -0
  26. data/app/assets/ahoy_analytics/build/assets/jsx-runtime-u17CrQMm.js +1 -0
  27. data/app/assets/ahoy_analytics/build/assets/last-load-context-De5uA95L.js +1 -0
  28. data/app/assets/ahoy_analytics/build/assets/list-table-ChHEzzF9.js +1 -0
  29. data/app/assets/ahoy_analytics/build/assets/live-Cp2MHECh.js +2 -0
  30. data/app/assets/ahoy_analytics/build/assets/locations-panel-BaISRmaQ.js +1 -0
  31. data/app/assets/ahoy_analytics/build/assets/mercator-BnxX5RzL.js +1 -0
  32. data/app/assets/ahoy_analytics/build/assets/pages-panel-Bh25L8mP.js +1 -0
  33. data/app/assets/ahoy_analytics/build/assets/panel-tabs-B2kvGFJx.js +1 -0
  34. data/app/assets/ahoy_analytics/build/assets/query-context-B-PgE00D.js +1 -0
  35. data/app/assets/ahoy_analytics/build/assets/remote-details-dialog-DDTcKaM5.js +1 -0
  36. data/app/assets/ahoy_analytics/build/assets/show-CCRicksg.js +1 -0
  37. data/app/assets/ahoy_analytics/build/assets/simple-tabs-D6G6Bs0k.js +1 -0
  38. data/app/assets/ahoy_analytics/build/assets/site-context-BNteYRlR.js +1 -0
  39. data/app/assets/ahoy_analytics/build/assets/sources-panel-DyB21hxD.js +1 -0
  40. data/app/assets/ahoy_analytics/build/assets/top-bar-FSiLBjq6.js +1 -0
  41. data/app/assets/ahoy_analytics/build/assets/top-stats-context-DU15P9jS.js +1 -0
  42. data/app/assets/ahoy_analytics/build/assets/use-debounce-VBpXQRL8.js +1 -0
  43. data/app/assets/ahoy_analytics/build/assets/user-context-DbYteluY.js +1 -0
  44. data/app/assets/ahoy_analytics/build/assets/visitor-globe-BWLDihid.js +4789 -0
  45. data/app/assets/ahoy_analytics/build/assets/visitor-graph-uKXjLvcu.js +1 -0
  46. data/app/assets/ahoy_analytics/images/icon/browser/brave.svg +1 -0
  47. data/app/assets/ahoy_analytics/images/icon/browser/chrome.svg +1 -0
  48. data/app/assets/ahoy_analytics/images/icon/browser/chromium.svg +1 -0
  49. data/app/assets/ahoy_analytics/images/icon/browser/duckduckgo.svg +2151 -0
  50. data/app/assets/ahoy_analytics/images/icon/browser/edge.svg +1 -0
  51. data/app/assets/ahoy_analytics/images/icon/browser/fallback.svg +5 -0
  52. data/app/assets/ahoy_analytics/images/icon/browser/firefox.svg +1 -0
  53. data/app/assets/ahoy_analytics/images/icon/browser/opera.svg +1 -0
  54. data/app/assets/ahoy_analytics/images/icon/browser/safari.png +0 -0
  55. data/app/assets/ahoy_analytics/images/icon/browser/samsung-internet.svg +1 -0
  56. data/app/assets/ahoy_analytics/images/icon/browser/uc.svg +1 -0
  57. data/app/assets/ahoy_analytics/images/icon/browser/vivaldi.svg +1 -0
  58. data/app/assets/ahoy_analytics/images/icon/browser/yandex.png +0 -0
  59. data/app/assets/ahoy_analytics/images/icon/os/android.png +0 -0
  60. data/app/assets/ahoy_analytics/images/icon/os/chrome_os.png +0 -0
  61. data/app/assets/ahoy_analytics/images/icon/os/fallback.svg +5 -0
  62. data/app/assets/ahoy_analytics/images/icon/os/fedora.png +0 -0
  63. data/app/assets/ahoy_analytics/images/icon/os/freebsd.png +0 -0
  64. data/app/assets/ahoy_analytics/images/icon/os/gnu_linux.png +0 -0
  65. data/app/assets/ahoy_analytics/images/icon/os/ios.png +0 -0
  66. data/app/assets/ahoy_analytics/images/icon/os/ipad_os.png +0 -0
  67. data/app/assets/ahoy_analytics/images/icon/os/mac.png +0 -0
  68. data/app/assets/ahoy_analytics/images/icon/os/ubuntu.png +0 -0
  69. data/app/assets/ahoy_analytics/images/icon/os/windows.png +0 -0
  70. data/app/assets/stylesheets/ahoy_analytics/application.css +15 -0
  71. data/app/channels/ahoy_analytics/analytics_channel.rb +9 -0
  72. data/app/controllers/ahoy_analytics/analytics_controller.rb +9 -0
  73. data/app/controllers/ahoy_analytics/application_controller.rb +8 -0
  74. data/app/controllers/ahoy_analytics/assets_controller.rb +46 -0
  75. data/app/controllers/ahoy_analytics/base_controller.rb +285 -0
  76. data/app/controllers/ahoy_analytics/behaviors_controller.rb +14 -0
  77. data/app/controllers/ahoy_analytics/devices_controller.rb +14 -0
  78. data/app/controllers/ahoy_analytics/export_controller.rb +12 -0
  79. data/app/controllers/ahoy_analytics/live_controller.rb +9 -0
  80. data/app/controllers/ahoy_analytics/locations_controller.rb +14 -0
  81. data/app/controllers/ahoy_analytics/main_graph_controller.rb +10 -0
  82. data/app/controllers/ahoy_analytics/pages_controller.rb +14 -0
  83. data/app/controllers/ahoy_analytics/referrers_controller.rb +34 -0
  84. data/app/controllers/ahoy_analytics/search_terms_controller.rb +47 -0
  85. data/app/controllers/ahoy_analytics/sources_controller.rb +14 -0
  86. data/app/controllers/ahoy_analytics/top_stats_controller.rb +13 -0
  87. data/app/controllers/concerns/ahoy_analytics/set_current_request.rb +17 -0
  88. data/app/frontend/components/analytics/hex-highlights.tsx +165 -0
  89. data/app/frontend/components/analytics/hex-land-layer.tsx +61 -0
  90. data/app/frontend/components/analytics/metric-card.tsx +138 -0
  91. data/app/frontend/components/analytics/sessions-by-location.tsx +62 -0
  92. data/app/frontend/components/analytics/visitor-globe.tsx +424 -0
  93. data/app/frontend/components/ui/accordion.tsx +64 -0
  94. data/app/frontend/components/ui/alert.tsx +66 -0
  95. data/app/frontend/components/ui/avatar.tsx +53 -0
  96. data/app/frontend/components/ui/badge.tsx +46 -0
  97. data/app/frontend/components/ui/button.tsx +62 -0
  98. data/app/frontend/components/ui/calendar.tsx +212 -0
  99. data/app/frontend/components/ui/card.tsx +91 -0
  100. data/app/frontend/components/ui/checkbox.tsx +32 -0
  101. data/app/frontend/components/ui/dropdown-menu.tsx +255 -0
  102. data/app/frontend/components/ui/input.tsx +21 -0
  103. data/app/frontend/components/ui/label.tsx +22 -0
  104. data/app/frontend/components/ui/popover.tsx +46 -0
  105. data/app/frontend/components/ui/select.tsx +183 -0
  106. data/app/frontend/components/ui/separator.tsx +26 -0
  107. data/app/frontend/components/ui/sheet.tsx +139 -0
  108. data/app/frontend/components/ui/sidebar.tsx +726 -0
  109. data/app/frontend/components/ui/skeleton.tsx +13 -0
  110. data/app/frontend/components/ui/sonner.tsx +33 -0
  111. data/app/frontend/components/ui/tooltip.tsx +59 -0
  112. data/app/frontend/data/countries-110m.json +1 -0
  113. data/app/frontend/data/globe-data.json +1 -0
  114. data/app/frontend/entrypoints/analytics-tracker.ts +680 -0
  115. data/app/frontend/entrypoints/analytics-ui.tsx +26 -0
  116. data/app/frontend/entrypoints/analytics.css +77 -0
  117. data/app/frontend/layouts/analytics-layout.tsx +28 -0
  118. data/app/frontend/lib/cable.ts +13 -0
  119. data/app/frontend/lib/geocode.ts +65 -0
  120. data/app/frontend/lib/utils.ts +6 -0
  121. data/app/frontend/pages/admin/analytics/api.ts +221 -0
  122. data/app/frontend/pages/admin/analytics/hooks/use-debounce.ts +36 -0
  123. data/app/frontend/pages/admin/analytics/last-load-context.tsx +29 -0
  124. data/app/frontend/pages/admin/analytics/lib/base-path.ts +28 -0
  125. data/app/frontend/pages/admin/analytics/lib/dialog-path.ts +242 -0
  126. data/app/frontend/pages/admin/analytics/lib/number-formatter.ts +100 -0
  127. data/app/frontend/pages/admin/analytics/live.tsx +608 -0
  128. data/app/frontend/pages/admin/analytics/query-context.tsx +61 -0
  129. data/app/frontend/pages/admin/analytics/show.tsx +40 -0
  130. data/app/frontend/pages/admin/analytics/site-context.tsx +22 -0
  131. data/app/frontend/pages/admin/analytics/top-stats-context.tsx +37 -0
  132. data/app/frontend/pages/admin/analytics/types.ts +161 -0
  133. data/app/frontend/pages/admin/analytics/ui/analytics-dashboard.tsx +60 -0
  134. data/app/frontend/pages/admin/analytics/ui/behaviors-panel.tsx +456 -0
  135. data/app/frontend/pages/admin/analytics/ui/date-range-dialog.tsx +173 -0
  136. data/app/frontend/pages/admin/analytics/ui/details-button.tsx +33 -0
  137. data/app/frontend/pages/admin/analytics/ui/devices-panel.tsx +474 -0
  138. data/app/frontend/pages/admin/analytics/ui/filter-dialog.tsx +558 -0
  139. data/app/frontend/pages/admin/analytics/ui/list-table.tsx +346 -0
  140. data/app/frontend/pages/admin/analytics/ui/locations-panel.tsx +566 -0
  141. data/app/frontend/pages/admin/analytics/ui/pages-panel.tsx +207 -0
  142. data/app/frontend/pages/admin/analytics/ui/panel-tabs.tsx +65 -0
  143. data/app/frontend/pages/admin/analytics/ui/remote-details-dialog.tsx +356 -0
  144. data/app/frontend/pages/admin/analytics/ui/simple-tabs.tsx +54 -0
  145. data/app/frontend/pages/admin/analytics/ui/sources-panel.tsx +771 -0
  146. data/app/frontend/pages/admin/analytics/ui/top-bar.tsx +793 -0
  147. data/app/frontend/pages/admin/analytics/ui/visitor-graph.tsx +891 -0
  148. data/app/frontend/pages/admin/analytics/user-context.tsx +22 -0
  149. data/app/frontend/styles/shared.css +156 -0
  150. data/app/helpers/ahoy_analytics/application_helper.rb +96 -0
  151. data/app/jobs/ahoy_analytics/application_job.rb +4 -0
  152. data/app/jobs/ahoy_analytics/update_job.rb +12 -0
  153. data/app/mailers/ahoy_analytics/application_mailer.rb +6 -0
  154. data/app/models/ahoy/event/filters.rb +7 -0
  155. data/app/models/ahoy/event.rb +9 -0
  156. data/app/models/ahoy/visit/cache_key.rb +15 -0
  157. data/app/models/ahoy/visit/constants.rb +11 -0
  158. data/app/models/ahoy/visit/devices.rb +144 -0
  159. data/app/models/ahoy/visit/export.rb +24 -0
  160. data/app/models/ahoy/visit/filters.rb +286 -0
  161. data/app/models/ahoy/visit/imports.rb +36 -0
  162. data/app/models/ahoy/visit/locations.rb +276 -0
  163. data/app/models/ahoy/visit/metrics.rb +473 -0
  164. data/app/models/ahoy/visit/ordering.rb +110 -0
  165. data/app/models/ahoy/visit/pages.rb +533 -0
  166. data/app/models/ahoy/visit/pagination.rb +17 -0
  167. data/app/models/ahoy/visit/ranges.rb +227 -0
  168. data/app/models/ahoy/visit/series.rb +177 -0
  169. data/app/models/ahoy/visit/sources.rb +418 -0
  170. data/app/models/ahoy/visit/url_labels.rb +32 -0
  171. data/app/models/ahoy/visit.rb +143 -0
  172. data/app/models/ahoy_analytics/application_record.rb +5 -0
  173. data/app/models/ahoy_analytics/current.rb +8 -0
  174. data/app/models/ahoy_analytics/funnel.rb +16 -0
  175. data/app/models/ahoy_analytics/imported_entry_page.rb +5 -0
  176. data/app/models/ahoy_analytics/imported_exit_page.rb +5 -0
  177. data/app/models/ahoy_analytics/imported_page.rb +5 -0
  178. data/app/models/ahoy_analytics/live_stats.rb +152 -0
  179. data/app/models/ahoy_analytics/setting.rb +19 -0
  180. data/app/models/analytics/source_catalog.rb +48 -0
  181. data/app/views/layouts/ahoy_analytics/application.html.erb +15 -0
  182. data/config/routes.rb +21 -0
  183. data/config/vite.json +22 -0
  184. data/db/migrate/20251006104056_create_ahoy_visits_and_events.rb +62 -0
  185. data/db/migrate/20251006105012_add_analytics_fields_to_ahoy_visits.rb +11 -0
  186. data/db/migrate/20251012090000_create_analytics_funnels_and_imports.rb +52 -0
  187. data/db/migrate/20251013021500_add_analytics_indexes.rb +14 -0
  188. data/lib/ahoy_analytics/ahoy_store.rb +429 -0
  189. data/lib/ahoy_analytics/asset_manifest.rb +56 -0
  190. data/lib/ahoy_analytics/device_bucket.rb +39 -0
  191. data/lib/ahoy_analytics/engine.rb +55 -0
  192. data/lib/ahoy_analytics/maxmind_geo.rb +77 -0
  193. data/lib/ahoy_analytics/version.rb +3 -0
  194. data/lib/ahoy_analytics.rb +52 -0
  195. data/lib/generators/ahoy_analytics/install/install_generator.rb +111 -0
  196. data/lib/generators/ahoy_analytics/install/templates/initializer.rb +28 -0
  197. data/lib/tasks/ahoy_analytics_tasks.rake +4 -0
  198. 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
+ }