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,558 @@
1
+ import { createPortal } from 'react-dom'
2
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
3
+ import { X } from 'lucide-react'
4
+
5
+ import { Button } from '@/components/ui/button'
6
+ import { Input } from '@/components/ui/input'
7
+ import { Label } from '@/components/ui/label'
8
+ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
9
+
10
+ import type { AnalyticsQuery, ListPayload } from '../types'
11
+ import { useQueryContext } from '../query-context'
12
+ import { fetchListPage } from '../api'
13
+ import { useDebounce } from '../hooks/use-debounce'
14
+ import { analyticsPath } from '../lib/base-path'
15
+
16
+ type DialogType = 'page' | 'location' | 'source' | 'utm' | 'browser' | 'os' | 'size' | 'goal'
17
+ type FilterDialogProps = {
18
+ open: boolean
19
+ onOpenChange: (open: boolean) => void
20
+ type: DialogType
21
+ }
22
+
23
+ export default function FilterDialog({ open, onOpenChange, type }: FilterDialogProps) {
24
+ const [mounted, setMounted] = useState(false)
25
+ const previousOverflow = useRef<string | null>(null)
26
+ const dialogRef = useRef<HTMLDivElement | null>(null)
27
+ const [canApply, setCanApply] = useState(false)
28
+ const applyRef = useRef<null | (() => void)>(null)
29
+ useEffect(() => setMounted(true), [])
30
+ useEffect(() => {
31
+ if (!open) {
32
+ if (previousOverflow.current != null) {
33
+ document.body.style.overflow = previousOverflow.current
34
+ previousOverflow.current = null
35
+ }
36
+ return
37
+ }
38
+ previousOverflow.current = document.body.style.overflow
39
+ document.body.style.overflow = 'hidden'
40
+ return () => {
41
+ if (previousOverflow.current != null) {
42
+ document.body.style.overflow = previousOverflow.current
43
+ previousOverflow.current = null
44
+ }
45
+ }
46
+ }, [open])
47
+
48
+ // ESC to close, focus management (match remote-details-dialog)
49
+ useEffect(() => {
50
+ if (!open) return
51
+ const onKey = (event: KeyboardEvent) => {
52
+ if (event.key === 'Escape') {
53
+ event.preventDefault()
54
+ onOpenChange(false)
55
+ }
56
+ }
57
+ window.addEventListener('keydown', onKey)
58
+ setTimeout(() => dialogRef.current?.focus(), 0)
59
+ return () => window.removeEventListener('keydown', onKey)
60
+ }, [open, onOpenChange])
61
+
62
+ if (!mounted || !open) return null
63
+ return createPortal(
64
+ <div
65
+ className="fixed inset-0 z-[60] flex items-start justify-center bg-slate-950/80 p-4 pt-10 sm:pt-10 md:pt-12 lg:pt-16 backdrop-blur-sm"
66
+ onClick={() => onOpenChange(false)}
67
+ >
68
+ <div
69
+ ref={dialogRef}
70
+ role="dialog"
71
+ aria-modal="true"
72
+ tabIndex={-1}
73
+ className="relative mx-auto flex h-[84vh] max-h-[84vh] w-full max-w-6xl flex-col rounded-xl border border-border bg-card shadow-[0_16px_48px_rgba(7,9,16,0.5)] outline-none"
74
+ onClick={(e) => e.stopPropagation()}
75
+ onKeyDown={(e) => {
76
+ if (e.key === 'Enter' && (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) === false) {
77
+ // Submit when a field is focused and Apply is enabled
78
+ if (canApply && applyRef.current) {
79
+ e.preventDefault();
80
+ applyRef.current();
81
+ }
82
+ }
83
+ }}
84
+ >
85
+ <header className="flex flex-col gap-2 border-b border-border px-6 py-4 sm:flex-row sm:items-center sm:justify-between md:px-8 md:py-5">
86
+ <h2 className="text-xl font-semibold text-foreground/90">{titleFor(type)}</h2>
87
+ <div className="flex items-center gap-3">
88
+ <Button variant="ghost" size="icon" onClick={() => onOpenChange(false)} aria-label="Close filter dialog">
89
+ <X className="size-5" />
90
+ </Button>
91
+ </div>
92
+ </header>
93
+
94
+ <div className="flex-1 overflow-hidden">
95
+ <div className="h-full overflow-y-auto p-6 md:p-8">
96
+ {type === 'page' ? (
97
+ <PageFilterForm
98
+ onDone={() => onOpenChange(false)}
99
+ onProvideControls={(apply, enabled) => { applyRef.current = apply; setCanApply(enabled) }}
100
+ />
101
+ ) : null}
102
+ {type === 'location' ? (
103
+ <LocationFilterForm
104
+ onDone={() => onOpenChange(false)}
105
+ onProvideControls={(apply, enabled) => { applyRef.current = apply; setCanApply(enabled) }}
106
+ />
107
+ ) : null}
108
+ {type === 'source' ? (
109
+ <SourceFilterForm
110
+ onDone={() => onOpenChange(false)}
111
+ onProvideControls={(apply, enabled) => { applyRef.current = apply; setCanApply(enabled) }}
112
+ />
113
+ ) : null}
114
+ {type === 'utm' ? (
115
+ <UtmFilterForm
116
+ onDone={() => onOpenChange(false)}
117
+ onProvideControls={(apply, enabled) => { applyRef.current = apply; setCanApply(enabled) }}
118
+ />
119
+ ) : null}
120
+ {type === 'browser' ? (
121
+ <DeviceFilterForm
122
+ dim="browser"
123
+ onDone={() => onOpenChange(false)}
124
+ onProvideControls={(apply, enabled) => { applyRef.current = apply; setCanApply(enabled) }}
125
+ />
126
+ ) : null}
127
+ {type === 'os' ? (
128
+ <DeviceFilterForm
129
+ dim="os"
130
+ onDone={() => onOpenChange(false)}
131
+ onProvideControls={(apply, enabled) => { applyRef.current = apply; setCanApply(enabled) }}
132
+ />
133
+ ) : null}
134
+ {type === 'size' ? (
135
+ <ScreenSizeFilterForm
136
+ onDone={() => onOpenChange(false)}
137
+ onProvideControls={(apply, enabled) => { applyRef.current = apply; setCanApply(enabled) }}
138
+ />
139
+ ) : null}
140
+ {type === 'goal' ? (
141
+ <GoalFilterForm
142
+ onDone={() => onOpenChange(false)}
143
+ onProvideControls={(apply, enabled) => { applyRef.current = apply; setCanApply(enabled) }}
144
+ />
145
+ ) : null}
146
+ </div>
147
+ </div>
148
+
149
+ <footer className="flex shrink-0 items-center justify-end gap-3 border-t border-border px-6 py-3 md:px-8 md:py-4">
150
+ <Button variant="ghost" onClick={() => onOpenChange(false)}>Cancel</Button>
151
+ <Button disabled={!canApply} onClick={() => applyRef.current && applyRef.current()}>Apply filter</Button>
152
+ </footer>
153
+ </div>
154
+ </div>,
155
+ document.body
156
+ )
157
+ }
158
+
159
+ function titleFor(type: DialogType) {
160
+ switch (type) {
161
+ case 'page': return 'Filter by Page'
162
+ case 'location': return 'Filter by Location'
163
+ case 'source': return 'Filter by Source'
164
+ case 'utm': return 'Filter by UTM tags'
165
+ default: return 'Filter'
166
+ }
167
+ }
168
+
169
+ type Operator = 'is' | 'is_not' | 'contains'
170
+
171
+ function PageFilterForm({ onDone, onProvideControls }: { onDone: () => void; onProvideControls: (apply: () => void, enabled: boolean) => void }) {
172
+ const { updateQuery } = useQueryContext()
173
+ const [pageOp, setPageOp] = useState<Operator>('is')
174
+ const [entryOp, setEntryOp] = useState<Operator>('is')
175
+ const [exitOp, setExitOp] = useState<Operator>('is')
176
+ const [pageVal, setPageVal] = useState('')
177
+ const [entryVal, setEntryVal] = useState('')
178
+ const [exitVal, setExitVal] = useState('')
179
+
180
+ const disabled = useMemo(() => {
181
+ return [pageVal, entryVal, exitVal].every((v) => v.trim() === '')
182
+ }, [pageVal, entryVal, exitVal])
183
+
184
+ const apply = useCallback(() => {
185
+ updateQuery((current) => {
186
+ let next: AnalyticsQuery = { ...current }
187
+ const nextFilters = { ...next.filters }
188
+ const nextAdvanced = Array.isArray(next.advancedFilters) ? [...next.advancedFilters] : []
189
+
190
+ function put(dim: 'page' | 'entry_page' | 'exit_page', op: Operator, value: string) {
191
+ const v = value.trim()
192
+ if (!v) return
193
+ // Remove existing entries for this dim first
194
+ delete nextFilters[dim]
195
+ for (let i = nextAdvanced.length - 1; i >= 0; i--) {
196
+ if (nextAdvanced[i][1] === dim) nextAdvanced.splice(i, 1)
197
+ }
198
+ if (op === 'is') {
199
+ nextFilters[dim] = v
200
+ } else {
201
+ nextAdvanced.push([op, dim, v])
202
+ }
203
+ }
204
+ put('page', pageOp, pageVal)
205
+ put('entry_page', entryOp, entryVal)
206
+ put('exit_page', exitOp, exitVal)
207
+ next = { ...next, filters: nextFilters, advancedFilters: nextAdvanced }
208
+ return next
209
+ })
210
+ onDone()
211
+ }, [updateQuery, pageOp, entryOp, exitOp, pageVal, entryVal, exitVal, onDone])
212
+
213
+ useEffect(() => {
214
+ onProvideControls(apply, !disabled)
215
+ }, [apply, disabled, onProvideControls])
216
+
217
+ return (
218
+ <form className="flex flex-col gap-3 md:gap-4" onSubmit={(e) => { e.preventDefault(); }}>
219
+ <FilterRow label="Page" operator={pageOp} onOperatorChange={setPageOp} value={pageVal} onValueChange={setPageVal} fetcher={usePageFetcher('default')} placeholder="Select a Page" />
220
+ <FilterRow label="Entry Page" operator={entryOp} onOperatorChange={setEntryOp} value={entryVal} onValueChange={setEntryVal} fetcher={usePageFetcher('entry')} placeholder="Select an Entry Page" />
221
+ <FilterRow label="Exit Page" operator={exitOp} onOperatorChange={setExitOp} value={exitVal} onValueChange={setExitVal} fetcher={usePageFetcher('exit')} placeholder="Select an Exit Page" />
222
+ </form>
223
+ )
224
+ }
225
+
226
+ function LocationFilterForm({ onDone, onProvideControls }: { onDone: () => void; onProvideControls: (apply: () => void, enabled: boolean) => void }) {
227
+ const { updateQuery } = useQueryContext()
228
+ const [countryOp, setCountryOp] = useState<Operator>('is')
229
+ const [regionOp, setRegionOp] = useState<Operator>('is')
230
+ const [cityOp, setCityOp] = useState<Operator>('is')
231
+ const [countryVal, setCountryVal] = useState('')
232
+ const [regionVal, setRegionVal] = useState('')
233
+ const [cityVal, setCityVal] = useState('')
234
+ const disabled = [countryVal, regionVal, cityVal].every((v) => v.trim() === '')
235
+ const apply = useCallback(() => {
236
+ updateQuery((current) => {
237
+ let next: AnalyticsQuery = { ...current }
238
+ const eq = { ...next.filters }
239
+ const adv = Array.isArray(next.advancedFilters) ? [...next.advancedFilters] : []
240
+ function put(dim: 'country' | 'region' | 'city', op: Operator, val: string) {
241
+ const v = val.trim(); if (!v) return
242
+ delete eq[dim]
243
+ for (let i = adv.length - 1; i >= 0; i--) { if (adv[i][1] === dim) adv.splice(i, 1) }
244
+ if (op === 'is') { eq[dim] = v } else { adv.push([op, dim, v]) }
245
+ }
246
+ put('country', countryOp, countryVal)
247
+ put('region', regionOp, regionVal)
248
+ put('city', cityOp, cityVal)
249
+ next = { ...next, filters: eq, advancedFilters: adv }
250
+ return next
251
+ })
252
+ onDone()
253
+ }, [updateQuery, countryOp, regionOp, cityOp, countryVal, regionVal, cityVal, onDone])
254
+ useEffect(() => { onProvideControls(apply, !disabled) }, [apply, disabled, onProvideControls])
255
+ return (
256
+ <form className="flex flex-col gap-4" onSubmit={(e) => { e.preventDefault() }}>
257
+ <FilterRow label="Country" operator={countryOp} onOperatorChange={setCountryOp} value={countryVal} onValueChange={setCountryVal} fetcher={useLocationFetcher('countries')} placeholder="Select a Country" />
258
+ <FilterRow label="Region" operator={regionOp} onOperatorChange={setRegionOp} value={regionVal} onValueChange={setRegionVal} fetcher={useLocationFetcher('regions')} placeholder="Select a Region" />
259
+ <FilterRow label="City" operator={cityOp} onOperatorChange={setCityOp} value={cityVal} onValueChange={setCityVal} fetcher={useLocationFetcher('cities')} placeholder="Select a City" />
260
+ </form>
261
+ )
262
+ }
263
+
264
+ function useLocationFetcher(mode: 'countries' | 'regions' | 'cities') {
265
+ const { query } = useQueryContext()
266
+ return useCallback(async (input: string) => {
267
+ try {
268
+ const payload: ListPayload = await fetchListPage(analyticsPath('locations'), query as AnalyticsQuery, { mode }, { limit: 20, page: 1, search: input })
269
+ return payload.results.map((it) => ({ label: String(it.name), value: String(it.code ?? it.name) }))
270
+ } catch {
271
+ return []
272
+ }
273
+ }, [query, mode])
274
+ }
275
+
276
+ function SourceFilterForm({ onDone, onProvideControls }: { onDone: () => void; onProvideControls: (apply: () => void, enabled: boolean) => void }) {
277
+ const { updateQuery } = useQueryContext()
278
+ const [op, setOp] = useState<Operator>('is')
279
+ const [val, setVal] = useState('')
280
+ const disabled = val.trim() === ''
281
+ const apply = useCallback(() => {
282
+ updateQuery((current) => {
283
+ let next: AnalyticsQuery = { ...current }
284
+ const eq = { ...next.filters }
285
+ const adv = Array.isArray(next.advancedFilters) ? [...next.advancedFilters] : []
286
+ delete eq['source']
287
+ for (let i = adv.length - 1; i >= 0; i--) { if (adv[i][1] === 'source') adv.splice(i, 1) }
288
+ if (op === 'is') { eq['source'] = val.trim() } else { adv.push([op, 'source', val.trim()]) }
289
+ return { ...next, filters: eq, advancedFilters: adv }
290
+ })
291
+ onDone()
292
+ }, [updateQuery, op, val, onDone])
293
+ useEffect(() => { onProvideControls(apply, !disabled) }, [apply, disabled, onProvideControls])
294
+ return (
295
+ <form className="flex flex-col gap-4" onSubmit={(e) => { e.preventDefault() }}>
296
+ <FilterRow label="Source" operator={op} onOperatorChange={setOp} value={val} onValueChange={setVal} fetcher={useSourcesFetcher('all')} placeholder="Select a Source" />
297
+ </form>
298
+ )
299
+ }
300
+
301
+ function useSourcesFetcher(mode: 'all' | 'utm-source' | 'utm-medium' | 'utm-campaign' | 'utm-content' | 'utm-term') {
302
+ const { query } = useQueryContext()
303
+ return useCallback(async (input: string) => {
304
+ try {
305
+ const payload: ListPayload = await fetchListPage(analyticsPath('sources'), query as AnalyticsQuery, { mode }, { limit: 20, page: 1, search: input })
306
+ return payload.results.map((it) => ({ label: String(it.name), value: String(it.name) }))
307
+ } catch {
308
+ return []
309
+ }
310
+ }, [query, mode])
311
+ }
312
+
313
+ function UtmFilterForm({ onDone, onProvideControls }: { onDone: () => void; onProvideControls: (apply: () => void, enabled: boolean) => void }) {
314
+ const { updateQuery } = useQueryContext()
315
+ const [sourceOp, setSourceOp] = useState<Operator>('is'); const [sourceVal, setSourceVal] = useState('')
316
+ const [mediumOp, setMediumOp] = useState<Operator>('is'); const [mediumVal, setMediumVal] = useState('')
317
+ const [campaignOp, setCampaignOp] = useState<Operator>('is'); const [campaignVal, setCampaignVal] = useState('')
318
+ const [contentOp, setContentOp] = useState<Operator>('is'); const [contentVal, setContentVal] = useState('')
319
+ const [termOp, setTermOp] = useState<Operator>('is'); const [termVal, setTermVal] = useState('')
320
+ const disabled = [sourceVal, mediumVal, campaignVal, contentVal, termVal].every((v) => v.trim() === '')
321
+ const apply = useCallback(() => {
322
+ updateQuery((current) => {
323
+ let next: AnalyticsQuery = { ...current }
324
+ const eq = { ...next.filters }
325
+ const adv = Array.isArray(next.advancedFilters) ? [...next.advancedFilters] : []
326
+ function put(dim: 'utm_source'|'utm_medium'|'utm_campaign'|'utm_content'|'utm_term', op: Operator, v: string) {
327
+ const val = v.trim(); if (!val) return
328
+ delete eq[dim]
329
+ for (let i = adv.length - 1; i >= 0; i--) { if (adv[i][1] === dim) adv.splice(i, 1) }
330
+ if (op === 'is') { eq[dim] = val } else { adv.push([op, dim, val]) }
331
+ }
332
+ put('utm_source', sourceOp, sourceVal)
333
+ put('utm_medium', mediumOp, mediumVal)
334
+ put('utm_campaign', campaignOp, campaignVal)
335
+ put('utm_content', contentOp, contentVal)
336
+ put('utm_term', termOp, termVal)
337
+ return { ...next, filters: eq, advancedFilters: adv }
338
+ })
339
+ onDone()
340
+ }, [updateQuery, sourceOp, sourceVal, mediumOp, mediumVal, campaignOp, campaignVal, contentOp, contentVal, termOp, termVal, onDone])
341
+ useEffect(() => { onProvideControls(apply, !disabled) }, [apply, disabled, onProvideControls])
342
+ return (
343
+ <form className="flex flex-col gap-4" onSubmit={(e) => { e.preventDefault() }}>
344
+ <FilterRow label="UTM Source" operator={sourceOp} onOperatorChange={setSourceOp} value={sourceVal} onValueChange={setSourceVal} fetcher={useSourcesFetcher('utm-source')} placeholder="Select a UTM Source" />
345
+ <FilterRow label="UTM Medium" operator={mediumOp} onOperatorChange={setMediumOp} value={mediumVal} onValueChange={setMediumVal} fetcher={useSourcesFetcher('utm-medium')} placeholder="Select a UTM Medium" />
346
+ <FilterRow label="UTM Campaign" operator={campaignOp} onOperatorChange={setCampaignOp} value={campaignVal} onValueChange={setCampaignVal} fetcher={useSourcesFetcher('utm-campaign')} placeholder="Select a UTM Campaign" />
347
+ <FilterRow label="UTM Content" operator={contentOp} onOperatorChange={setContentOp} value={contentVal} onValueChange={setContentVal} fetcher={useSourcesFetcher('utm-content')} placeholder="Select a UTM Content" />
348
+ <FilterRow label="UTM Term" operator={termOp} onOperatorChange={setTermOp} value={termVal} onValueChange={setTermVal} fetcher={useSourcesFetcher('utm-term')} placeholder="Select a UTM Term" />
349
+ </form>
350
+ )
351
+ }
352
+
353
+ function DeviceFilterForm({ dim, onDone, onProvideControls }: { dim: 'browser' | 'os'; onDone: () => void; onProvideControls: (apply: () => void, enabled: boolean) => void }) {
354
+ const { updateQuery } = useQueryContext()
355
+ const [op, setOp] = useState<Operator>('is')
356
+ const [val, setVal] = useState('')
357
+ const disabled = val.trim() === ''
358
+ const apply = useCallback(() => {
359
+ updateQuery((current) => {
360
+ let next: AnalyticsQuery = { ...current }
361
+ const eq = { ...next.filters }
362
+ const adv = Array.isArray(next.advancedFilters) ? [...next.advancedFilters] : []
363
+ delete eq[dim]
364
+ for (let i = adv.length - 1; i >= 0; i--) { if (adv[i][1] === dim) adv.splice(i, 1) }
365
+ if (op === 'is') { eq[dim] = val.trim() } else { adv.push([op, dim, val.trim()]) }
366
+ return { ...next, filters: eq, advancedFilters: adv }
367
+ })
368
+ onDone()
369
+ }, [updateQuery, op, val, dim, onDone])
370
+ useEffect(() => { onProvideControls(apply, !disabled) }, [apply, disabled, onProvideControls])
371
+ const mode = dim === 'browser' ? 'browsers' : 'operating-systems'
372
+ return (
373
+ <form className="flex flex-col gap-4" onSubmit={(e) => { e.preventDefault() }}>
374
+ <FilterRow label={dim === 'browser' ? 'Browser' : 'Operating System'} operator={op} onOperatorChange={setOp} value={val} onValueChange={setVal} fetcher={useDeviceFetcher(mode)} placeholder={`Select a ${dim === 'browser' ? 'Browser' : 'OS'}`} />
375
+ </form>
376
+ )
377
+ }
378
+
379
+ function useDeviceFetcher(mode: 'browsers' | 'operating-systems' | 'screen-sizes') {
380
+ const { query } = useQueryContext()
381
+ return useCallback(async (input: string) => {
382
+ try {
383
+ const payload: ListPayload = await fetchListPage(analyticsPath('devices'), query as AnalyticsQuery, { mode }, { limit: 20, page: 1, search: input })
384
+ return payload.results.map((it) => ({ label: String(it.name), value: String(it.name) }))
385
+ } catch {
386
+ return []
387
+ }
388
+ }, [query, mode])
389
+ }
390
+
391
+ function ScreenSizeFilterForm({ onDone, onProvideControls }: { onDone: () => void; onProvideControls: (apply: () => void, enabled: boolean) => void }) {
392
+ const { updateQuery } = useQueryContext()
393
+ const [op, setOp] = useState<Operator>('is')
394
+ const [val, setVal] = useState('')
395
+ const disabled = val.trim() === ''
396
+ const apply = useCallback(() => {
397
+ updateQuery((current) => {
398
+ let next: AnalyticsQuery = { ...current }
399
+ const eq = { ...next.filters }
400
+ const adv = Array.isArray(next.advancedFilters) ? [...next.advancedFilters] : []
401
+ delete eq['size']
402
+ for (let i = adv.length - 1; i >= 0; i--) { if (adv[i][1] === 'size') adv.splice(i, 1) }
403
+ if (op === 'is') { eq['size'] = val.trim() } else { adv.push([op, 'size', val.trim()]) }
404
+ return { ...next, filters: eq, advancedFilters: adv }
405
+ })
406
+ onDone()
407
+ }, [updateQuery, op, val, onDone])
408
+ useEffect(() => { onProvideControls(apply, !disabled) }, [apply, disabled, onProvideControls])
409
+ return (
410
+ <form className="flex flex-col gap-4" onSubmit={(e) => { e.preventDefault() }}>
411
+ <FilterRow label="Screen Size" operator={op} onOperatorChange={setOp} value={val} onValueChange={setVal} fetcher={useDeviceFetcher('screen-sizes')} placeholder="Select a Screen Size" />
412
+ </form>
413
+ )
414
+ }
415
+
416
+ function GoalFilterForm({ onDone, onProvideControls }: { onDone: () => void; onProvideControls: (apply: () => void, enabled: boolean) => void }) {
417
+ const { updateQuery } = useQueryContext()
418
+ const [op, setOp] = useState<Operator>('is')
419
+ const [val, setVal] = useState('')
420
+ const disabled = val.trim() === ''
421
+ const apply = useCallback(() => {
422
+ updateQuery((current) => {
423
+ let next: AnalyticsQuery = { ...current }
424
+ const eq = { ...next.filters }
425
+ const adv = Array.isArray(next.advancedFilters) ? [...next.advancedFilters] : []
426
+ delete eq['goal']
427
+ for (let i = adv.length - 1; i >= 0; i--) { if (adv[i][1] === 'goal') adv.splice(i, 1) }
428
+ if (op === 'is') { eq['goal'] = val.trim() } else { adv.push([op, 'goal', val.trim()]) }
429
+ return { ...next, filters: eq, advancedFilters: adv }
430
+ })
431
+ onDone()
432
+ }, [updateQuery, op, val, onDone])
433
+ useEffect(() => { onProvideControls(apply, !disabled) }, [apply, disabled, onProvideControls])
434
+ return (
435
+ <form className="flex flex-col gap-4" onSubmit={(e) => { e.preventDefault() }}>
436
+ <FilterRow label="Goal" operator={op} onOperatorChange={setOp} value={val} onValueChange={setVal} fetcher={useBehaviorFetcher('conversions')} placeholder="Select a Goal" />
437
+ </form>
438
+ )
439
+ }
440
+
441
+ function useBehaviorFetcher(mode: 'conversions') {
442
+ const { query } = useQueryContext()
443
+ return useCallback(async (input: string) => {
444
+ try {
445
+ const payload: ListPayload = await fetchListPage(analyticsPath('behaviors'), query as AnalyticsQuery, { mode }, { limit: 20, page: 1, search: input })
446
+ return payload.results.map((it) => ({ label: String(it.name), value: String(it.name) }))
447
+ } catch {
448
+ return []
449
+ }
450
+ }, [query, mode])
451
+ }
452
+
453
+ function FilterRow({ label, operator, onOperatorChange, value, onValueChange, fetcher, placeholder }: {
454
+ label: string
455
+ operator: Operator
456
+ onOperatorChange: (op: Operator) => void
457
+ value: string
458
+ onValueChange: (v: string) => void
459
+ fetcher: (q: string) => Promise<Array<{ label: string; value: string }>>
460
+ placeholder: string
461
+ }) {
462
+ return (
463
+ <div className="grid grid-cols-1 gap-1.5 md:grid-cols-[max-content_minmax(0,1fr)] md:items-center">
464
+ <Label className="text-sm text-muted-foreground md:self-center">{label}</Label>
465
+ <div className="flex flex-col gap-2 sm:flex-row sm:items-center">
466
+ <Select value={operator} onValueChange={(v: string) => onOperatorChange(v as Operator)}>
467
+ <SelectTrigger className="h-9 w-24 md:w-28 shrink-0 rounded-md">
468
+ <SelectValue />
469
+ </SelectTrigger>
470
+ <SelectContent className="z-[70]">
471
+ <SelectItem value="is">is</SelectItem>
472
+ <SelectItem value="is_not">is not</SelectItem>
473
+ <SelectItem value="contains">contains</SelectItem>
474
+ </SelectContent>
475
+ </Select>
476
+ <div className="min-w-0 flex-1">
477
+ <SuggestInput value={value} onChange={onValueChange} fetcher={fetcher} placeholder={placeholder} />
478
+ </div>
479
+ </div>
480
+ </div>
481
+ )
482
+ }
483
+
484
+ function usePageFetcher(mode: 'default' | 'entry' | 'exit') {
485
+ const { query } = useQueryContext()
486
+ return useCallback(async (input: string) => {
487
+ const extras: Record<string, string> = {}
488
+ if (mode === 'entry') extras.mode = 'entry'
489
+ if (mode === 'exit') extras.mode = 'exit'
490
+ try {
491
+ const payload: ListPayload = await fetchListPage(analyticsPath('pages'), query as AnalyticsQuery, extras, { limit: 20, page: 1, search: input })
492
+ return payload.results.map((it) => ({ label: String(it.name), value: String(it.name) }))
493
+ } catch {
494
+ return []
495
+ }
496
+ }, [query, mode])
497
+ }
498
+
499
+ function SuggestInput({ value, onChange, fetcher, placeholder, disabled }: {
500
+ value: string
501
+ onChange: (v: string) => void
502
+ fetcher: (q: string) => Promise<Array<{ label: string; value: string }>>
503
+ placeholder?: string
504
+ disabled?: boolean
505
+ }) {
506
+ const [open, setOpen] = useState(false)
507
+ const [options, setOptions] = useState<Array<{ label: string; value: string }>>([])
508
+ const [loading, setLoading] = useState(false)
509
+ const [query, setQuery] = useState('')
510
+ const debounced = useDebounce(async (q: string) => {
511
+ setLoading(true)
512
+ const res = await fetcher(q)
513
+ setOptions(res)
514
+ setLoading(false)
515
+ }, 250)
516
+
517
+ useEffect(() => {
518
+ if (!open) return
519
+ debounced(query)
520
+ }, [open, query])
521
+
522
+ return (
523
+ <div className="relative">
524
+ <Input
525
+ value={value}
526
+ onChange={(e) => { setOpen(true); setQuery(e.target.value); onChange(e.target.value) }}
527
+ onFocus={() => setOpen(true)}
528
+ onBlur={() => setTimeout(() => setOpen(false), 120)}
529
+ placeholder={placeholder}
530
+ disabled={disabled}
531
+ className="h-9"
532
+ />
533
+ {open && (
534
+ <div className="absolute z-50 mt-1 w-full overflow-hidden rounded-md border border-border bg-background shadow-lg">
535
+ <div className="max-h-60 overflow-auto text-sm">
536
+ {loading ? (
537
+ <div className="px-3 py-2 text-muted-foreground">Searching…</div>
538
+ ) : options.length === 0 ? (
539
+ <div className="px-3 py-2 text-muted-foreground">No matches</div>
540
+ ) : (
541
+ options.map((opt) => (
542
+ <button
543
+ key={opt.value}
544
+ type="button"
545
+ className="block w-full cursor-pointer px-3 py-2 text-left hover:bg-muted/50"
546
+ onMouseDown={(e) => e.preventDefault()}
547
+ onClick={() => { onChange(opt.value); setOpen(false) }}
548
+ >
549
+ {opt.label}
550
+ </button>
551
+ ))
552
+ )}
553
+ </div>
554
+ </div>
555
+ )}
556
+ </div>
557
+ )
558
+ }