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,207 @@
1
+ import { useCallback, useEffect, useMemo, useState } from 'react'
2
+
3
+ import { fetchPages } from '../api'
4
+ import { useQueryContext } from '../query-context'
5
+ import type { ListMetricKey, ListPayload } from '../types'
6
+ import { useSiteContext } from '../site-context'
7
+ import { MetricTable } from './list-table'
8
+ import RemoteDetailsDialog from './remote-details-dialog'
9
+ import {
10
+ parseDialogFromPath,
11
+ buildDialogPath,
12
+ baseAnalyticsPath,
13
+ pagesSegmentForMode,
14
+ pagesModeForSegment
15
+ } from '../lib/dialog-path'
16
+ import { analyticsPath } from '../lib/base-path'
17
+ import DetailsButton from './details-button'
18
+ import { PanelTab, PanelTabs } from './panel-tabs'
19
+
20
+ const PAGE_TABS: Array<{ value: string; label: string; short: string }> = [
21
+ { value: 'pages', label: 'Top Pages', short: 'Top Pages' },
22
+ { value: 'entry', label: 'Entry Pages', short: 'Entry Pages' },
23
+ { value: 'exit', label: 'Exit Pages', short: 'Exit Pages' }
24
+ ]
25
+
26
+ const TITLE_FOR_MODE: Record<string, string> = {
27
+ pages: 'Top Pages',
28
+ entry: 'Entry Pages',
29
+ exit: 'Exit Pages'
30
+ }
31
+
32
+ const STORAGE_PREFIX = 'admin.analytics.pages'
33
+
34
+ type PagesPanelProps = {
35
+ initialData: ListPayload
36
+ }
37
+
38
+ export default function PagesPanel({ initialData }: PagesPanelProps) {
39
+ const { query, updateQuery } = useQueryContext()
40
+ const site = useSiteContext()
41
+
42
+ const [data, setData] = useState<ListPayload>(initialData)
43
+ const [mode, setMode] = useState(() => {
44
+ if (typeof window === 'undefined') {
45
+ return 'pages'
46
+ }
47
+ const stored = localStorage.getItem(`${STORAGE_PREFIX}.${site.domain}`)
48
+ return stored && PAGE_TABS.some((tab) => tab.value === stored) ? stored : 'pages'
49
+ })
50
+ const [loading, setLoading] = useState(false)
51
+ const [detailsOpen, setDetailsOpen] = useState(false)
52
+
53
+ const highlightMetric = useMemo(
54
+ () => (data.metrics.includes('visitors') ? 'visitors' : data.metrics[0]),
55
+ [data.metrics]
56
+ )
57
+
58
+ const activeTitle = useMemo(() => TITLE_FOR_MODE[mode] ?? 'Pages', [mode])
59
+
60
+ const firstColumnLabel = useMemo(() => {
61
+ switch (mode) {
62
+ case 'entry':
63
+ return 'Entry page'
64
+ case 'exit':
65
+ return 'Exit page'
66
+ default:
67
+ return 'Page'
68
+ }
69
+ }, [mode])
70
+
71
+ useEffect(() => {
72
+ const controller = new AbortController()
73
+ setLoading(true)
74
+ fetchPages(query, { mode }, controller.signal)
75
+ .then(setData)
76
+ .catch((error) => {
77
+ if (error.name !== 'AbortError') console.error(error)
78
+ })
79
+ .finally(() => setLoading(false))
80
+ return () => controller.abort()
81
+ }, [mode, query])
82
+
83
+ // Deep-link: open Pages dialog for /_/pages, /_/entry-pages, /_/exit-pages
84
+ useEffect(() => {
85
+ const parsed = parseDialogFromPath(window.location.pathname)
86
+ if (parsed.type === 'segment') {
87
+ const modeFromSeg = pagesModeForSegment(parsed.segment)
88
+ if (modeFromSeg) {
89
+ if (mode !== modeFromSeg) setMode(modeFromSeg)
90
+ setDetailsOpen(true)
91
+ }
92
+ }
93
+ }, [])
94
+
95
+ const drillKey = useMemo(() => {
96
+ switch (mode) {
97
+ case 'entry':
98
+ return 'entry_page'
99
+ case 'exit':
100
+ return 'exit_page'
101
+ default:
102
+ return 'page'
103
+ }
104
+ }, [mode])
105
+
106
+ const drillInto = useCallback(
107
+ (value: string) => {
108
+ updateQuery((current) => ({
109
+ ...current,
110
+ filters: { ...current.filters, [drillKey]: value }
111
+ }))
112
+ },
113
+ [drillKey, updateQuery]
114
+ )
115
+
116
+ // Limit card view to top 9 by the first metric; Details uses full list
117
+ const limitedData = useMemo((): ListPayload => {
118
+ const metricKey = data.metrics[0] ?? 'visitors'
119
+ const sorted = [...data.results].sort((a, b) => {
120
+ const av = Number(a[metricKey] ?? 0)
121
+ const bv = Number(b[metricKey] ?? 0)
122
+ if (av === bv) return String(a.name).localeCompare(String(b.name))
123
+ return bv - av
124
+ })
125
+ const sliced = sorted.slice(0, 9)
126
+ return { ...data, metrics: ['visitors'] as ListMetricKey[], results: sliced, meta: { ...data.meta, hasMore: data.results.length > 9 } }
127
+ }, [data])
128
+
129
+ return (
130
+ <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)]" data-testid="pages-panel">
131
+ <header className="flex flex-wrap items-center justify-between gap-3">
132
+ <h2 className="text-lg/6 font-semibold text-foreground/80">{activeTitle}</h2>
133
+ <PanelTabs>
134
+ {PAGE_TABS.map((tab) => (
135
+ <PanelTab
136
+ key={tab.value}
137
+ active={mode === tab.value}
138
+ onClick={() => {
139
+ setMode(tab.value)
140
+ localStorage.setItem(`${STORAGE_PREFIX}.${site.domain}`, tab.value)
141
+ }}
142
+ >
143
+ {tab.short}
144
+ </PanelTab>
145
+ ))}
146
+ </PanelTabs>
147
+ </header>
148
+
149
+ {loading ? (
150
+ <div className="flex h-40 items-center justify-center text-sm text-muted-foreground">Loading…</div>
151
+ ) : data.results.length === 0 ? (
152
+ <div className="flex h-40 items-center justify-center text-sm text-muted-foreground">No data yet</div>
153
+ ) : (
154
+ <>
155
+ <MetricTable
156
+ data={limitedData}
157
+ highlightedMetric={highlightMetric ?? 'visitors'}
158
+ onRowClick={(item) => drillInto(String(item.name))}
159
+ displayBars={false}
160
+ firstColumnLabel={firstColumnLabel}
161
+ metricLabels={mode === 'entry' ? { visitors: 'Unique Entrances' } : (mode === 'exit' ? { visitors: 'Unique Exits' } : undefined)}
162
+ barColorTheme="cyan"
163
+ testId="pages"
164
+ />
165
+ <div className="mt-auto flex justify-center pt-3">
166
+ <DetailsButton data-testid="pages-details-btn" onClick={() => {
167
+ setDetailsOpen(true)
168
+ try {
169
+ const sp = new URLSearchParams(window.location.search)
170
+ sp.delete('dialog'); sp.delete('mode')
171
+ const seg = pagesSegmentForMode(mode as 'pages' | 'entry' | 'exit')
172
+ window.history.pushState({}, '', buildDialogPath(seg, sp.toString()))
173
+ } catch {}
174
+ }}>Details</DetailsButton>
175
+ </div>
176
+ </>
177
+ )}
178
+
179
+ <RemoteDetailsDialog
180
+ open={detailsOpen}
181
+ onOpenChange={(open) => {
182
+ setDetailsOpen(open)
183
+ try {
184
+ const sp = new URLSearchParams(window.location.search)
185
+ sp.delete('dialog'); sp.delete('mode')
186
+ const qs = sp.toString()
187
+ if (open) {
188
+ const seg = pagesSegmentForMode(mode as 'pages' | 'entry' | 'exit')
189
+ window.history.pushState({}, '', buildDialogPath(seg, qs))
190
+ } else {
191
+ window.history.pushState({}, '', baseAnalyticsPath(qs))
192
+ }
193
+ } catch {}
194
+ }}
195
+ title={activeTitle}
196
+ endpoint={analyticsPath('pages')}
197
+ extras={{ mode }}
198
+ defaultSortKey={'visitors'}
199
+ firstColumnLabel={firstColumnLabel}
200
+ onRowClick={(item) => {
201
+ drillInto(String(item.name))
202
+ setDetailsOpen(false)
203
+ }}
204
+ />
205
+ </section>
206
+ )
207
+ }
@@ -0,0 +1,65 @@
1
+ import { ReactNode } from 'react'
2
+ import { ChevronDown } from 'lucide-react'
3
+
4
+ import {
5
+ DropdownMenu,
6
+ DropdownMenuContent,
7
+ DropdownMenuItem,
8
+ DropdownMenuTrigger
9
+ } from '@/components/ui/dropdown-menu'
10
+
11
+ export function PanelTabs({ children }: { children: ReactNode }) {
12
+ return <div className="flex items-center gap-4 text-sm font-semibold">{children}</div>
13
+ }
14
+
15
+ export function PanelTab({ active, onClick, children }: { active: boolean; onClick: () => void; children: ReactNode }) {
16
+ return (
17
+ <button
18
+ type="button"
19
+ onClick={onClick}
20
+ className={[
21
+ 'rounded-none border-b-2 pb-1 transition-colors',
22
+ active ? 'border-primary text-primary' : 'border-transparent text-muted-foreground hover:text-primary'
23
+ ].join(' ')}
24
+ >
25
+ {children}
26
+ </button>
27
+ )
28
+ }
29
+
30
+ type PanelTabDropdownProps = {
31
+ active: boolean
32
+ label: string
33
+ options: Array<{ label: string; value: string }>
34
+ onSelect: (value: string) => void
35
+ }
36
+
37
+ export function PanelTabDropdown({ active, label, options, onSelect }: PanelTabDropdownProps) {
38
+ return (
39
+ <DropdownMenu>
40
+ <DropdownMenuTrigger asChild>
41
+ <button
42
+ type="button"
43
+ className={[
44
+ 'inline-flex items-center gap-1 border-b-2 pb-1 transition-colors',
45
+ active ? 'border-primary text-primary' : 'border-transparent hover:text-primary'
46
+ ].join(' ')}
47
+ >
48
+ {label}
49
+ <ChevronDown className="size-3.5" aria-hidden="true" />
50
+ </button>
51
+ </DropdownMenuTrigger>
52
+ <DropdownMenuContent align="end" className="w-48 text-sm">
53
+ {options.map((option) => (
54
+ <DropdownMenuItem
55
+ key={option.value}
56
+ onClick={() => onSelect(option.value)}
57
+ className="cursor-pointer"
58
+ >
59
+ {option.label}
60
+ </DropdownMenuItem>
61
+ ))}
62
+ </DropdownMenuContent>
63
+ </DropdownMenu>
64
+ )
65
+ }
@@ -0,0 +1,356 @@
1
+ import { createPortal } from 'react-dom'
2
+ import {
3
+ useCallback,
4
+ useEffect,
5
+ useRef,
6
+ useState,
7
+ type KeyboardEvent as ReactKeyboardEvent,
8
+ type ChangeEvent
9
+ } from 'react'
10
+
11
+ import { X } from 'lucide-react'
12
+ import { Input } from '@/components/ui/input'
13
+ import { Button } from '@/components/ui/button'
14
+
15
+ import type { AnalyticsQuery, ListItem, ListMetricKey, ListPayload } from '../types'
16
+ import { FORMATTERS, METRIC_LABELS, renderFlag } from './list-table'
17
+ import { useQueryContext } from '../query-context'
18
+ import { fetchListPage } from '../api'
19
+ import { useDebounce } from '../hooks/use-debounce'
20
+
21
+ type SortState = {
22
+ key: 'name' | ListMetricKey
23
+ direction: 'asc' | 'desc'
24
+ }
25
+
26
+ type RemoteDetailsDialogProps = {
27
+ open: boolean
28
+ onOpenChange: (open: boolean) => void
29
+ title: string
30
+ endpoint: string
31
+ extras?: Record<string, unknown>
32
+ firstColumnLabel?: string
33
+ onRowClick?: (item: ListItem) => void
34
+ renderLeading?: (item: ListItem) => React.ReactNode
35
+ initialLimit?: number
36
+ getExternalLinkUrl?: (item: ListItem) => string | null
37
+ sortable?: boolean
38
+ defaultSortKey?: SortState['key']
39
+ initialSearch?: string
40
+ }
41
+
42
+ export default function RemoteDetailsDialog({
43
+ open,
44
+ onOpenChange,
45
+ title,
46
+ endpoint,
47
+ extras = {},
48
+ firstColumnLabel = 'Item',
49
+ onRowClick,
50
+ renderLeading,
51
+ initialLimit = 100,
52
+ getExternalLinkUrl,
53
+ sortable = true,
54
+ defaultSortKey,
55
+ initialSearch
56
+ }: RemoteDetailsDialogProps) {
57
+ const { query } = useQueryContext()
58
+ const [mounted, setMounted] = useState(false)
59
+ const [search, setSearch] = useState('')
60
+ const [debouncedSearch, setDebouncedSearch] = useState('')
61
+ const [sort, setSort] = useState<SortState>(() => {
62
+ const key = defaultSortKey ?? 'visitDuration'
63
+ return { key, direction: key === 'name' ? 'asc' : 'desc' }
64
+ })
65
+ const [metrics, setMetrics] = useState<ListMetricKey[]>(['visitors'])
66
+ const [metricLabels, setMetricLabels] = useState<Record<string, string>>({})
67
+ const [items, setItems] = useState<ListItem[]>([])
68
+ const [page, setPage] = useState(1)
69
+ const [hasMore, setHasMore] = useState(false)
70
+ const [loading, setLoading] = useState(false)
71
+ const previousOverflow = useRef<string | null>(null)
72
+ const inputRef = useRef<HTMLInputElement | null>(null)
73
+ const dialogRef = useRef<HTMLDivElement | null>(null)
74
+
75
+ // Debounced search following Plausible's 300ms pattern
76
+ const debouncedSetSearch = useDebounce<(value: string) => void>((value: string) => {
77
+ setDebouncedSearch(value)
78
+ setPage(1) // Reset to first page on search
79
+ }, 300)
80
+
81
+ const handleSearchChange = useCallback((e: ChangeEvent<HTMLInputElement>) => {
82
+ const value = e.target.value
83
+ setSearch(value)
84
+ debouncedSetSearch(value)
85
+ }, [debouncedSetSearch])
86
+
87
+ // Mount tracking
88
+ useEffect(() => setMounted(true), [])
89
+
90
+ // Seed search on open (useful for props subset)
91
+ useEffect(() => {
92
+ if (open && initialSearch && !search) {
93
+ setSearch(initialSearch)
94
+ setDebouncedSearch(initialSearch)
95
+ setPage(1)
96
+ }
97
+ // eslint-disable-next-line react-hooks/exhaustive-deps
98
+ }, [open, initialSearch])
99
+
100
+ // Body scroll lock
101
+ useEffect(() => {
102
+ if (!open) {
103
+ if (previousOverflow.current != null) {
104
+ document.body.style.overflow = previousOverflow.current
105
+ previousOverflow.current = null
106
+ }
107
+ return
108
+ }
109
+ previousOverflow.current = document.body.style.overflow
110
+ document.body.style.overflow = 'hidden'
111
+ return () => {
112
+ if (previousOverflow.current != null) {
113
+ document.body.style.overflow = previousOverflow.current
114
+ previousOverflow.current = null
115
+ }
116
+ }
117
+ }, [open])
118
+
119
+ // Focus handling
120
+ useEffect(() => {
121
+ if (open) {
122
+ setTimeout(() => dialogRef.current?.focus(), 0)
123
+ }
124
+ }, [open])
125
+
126
+ const handleKeyDown = useCallback((event: KeyboardEvent) => {
127
+ if (event.key === 'Escape') {
128
+ event.preventDefault()
129
+ onOpenChange(false)
130
+ }
131
+ if (event.key === '/' && !(event.target instanceof HTMLInputElement)) {
132
+ event.preventDefault()
133
+ inputRef.current?.focus()
134
+ }
135
+ }, [onOpenChange])
136
+
137
+ useEffect(() => {
138
+ if (!open) return
139
+ window.addEventListener('keydown', handleKeyDown)
140
+ return () => window.removeEventListener('keydown', handleKeyDown)
141
+ }, [open, handleKeyDown])
142
+
143
+ // Fetch first page when opened or when query/extras/debouncedSearch/sort change
144
+ // Following Plausible's pattern: new search resets page to 1
145
+ useEffect(() => {
146
+ if (!open) return
147
+ let aborted = false
148
+ setLoading(true)
149
+ setPage(1)
150
+
151
+ // Build order_by following Plausible's format: [["metric", "direction"]]
152
+ const orderBy = sortable ? [[sort.key, sort.direction]] : undefined
153
+
154
+ fetchListPage(endpoint, query as AnalyticsQuery, extras, {
155
+ limit: initialLimit,
156
+ page: 1,
157
+ search: debouncedSearch,
158
+ orderBy
159
+ })
160
+ .then((rawPayload: any) => {
161
+ const payload: ListPayload = (rawPayload && (rawPayload.results || rawPayload.metrics)) ? rawPayload : rawPayload?.list || rawPayload
162
+ if (aborted) return
163
+ setItems(payload.results.map(normalizeItemKeys))
164
+ const normalizedMetrics = payload.metrics.map(normalizeMetricKey) as ListMetricKey[]
165
+ setMetrics(normalizedMetrics)
166
+ const labels = (payload.meta as any).metricLabels || (payload.meta as any).metric_labels
167
+ if (labels && typeof labels === 'object') {
168
+ // Normalize keys to camelCase to match ListMetricKey in UI
169
+ const normalized: Record<string, string> = {}
170
+ for (const [k, v] of Object.entries(labels as Record<string, string>)) {
171
+ const ck = k.includes('_') ? k.replace(/_([a-z])/g, (_, c) => c.toUpperCase()) : k
172
+ normalized[ck] = String(v)
173
+ }
174
+ setMetricLabels(normalized)
175
+ } else {
176
+ setMetricLabels({})
177
+ }
178
+ // If current sort key is not available, fall back to visitors (desc) or name (asc)
179
+ if (!normalizedMetrics.includes(sort.key as ListMetricKey) && sort.key !== 'name') {
180
+ setSort({ key: normalizedMetrics.includes('visitors') ? 'visitors' : 'name', direction: normalizedMetrics.includes('visitors') ? 'desc' : 'asc' })
181
+ }
182
+ setHasMore(Boolean(payload.meta?.hasMore))
183
+ })
184
+ .catch((err) => { if (!aborted) console.error(err) })
185
+ .finally(() => { if (!aborted) setLoading(false) })
186
+ return () => { aborted = true }
187
+ }, [open, endpoint, JSON.stringify(extras), JSON.stringify(query), debouncedSearch, sort.key, sort.direction, initialLimit])
188
+
189
+ const loadMore = useCallback(() => {
190
+ const next = page + 1
191
+ setLoading(true)
192
+ let aborted = false
193
+
194
+ // Build order_by following Plausible's format: [["metric", "direction"]]
195
+ const orderBy = [[sort.key, sort.direction]]
196
+
197
+ fetchListPage(endpoint, query as AnalyticsQuery, extras, {
198
+ limit: initialLimit,
199
+ page: next,
200
+ search: debouncedSearch,
201
+ orderBy
202
+ })
203
+ .then((rawPayload: any) => {
204
+ const payload: ListPayload = (rawPayload && (rawPayload.results || rawPayload.metrics)) ? rawPayload : rawPayload?.list || rawPayload
205
+ if (aborted) return
206
+ setItems((prev) => prev.concat(payload.results.map(normalizeItemKeys)))
207
+ setHasMore(Boolean(payload.meta?.hasMore))
208
+ setPage(next)
209
+ })
210
+ .catch((err) => { if (!aborted) console.error(err) })
211
+ .finally(() => { if (!aborted) setLoading(false) })
212
+ return () => { aborted = true }
213
+ }, [endpoint, query, extras, page, initialLimit, debouncedSearch, sort.key, sort.direction])
214
+
215
+ const handleClose = useCallback(() => {
216
+ onOpenChange(false)
217
+ setSearch('')
218
+ setDebouncedSearch('')
219
+ setItems([])
220
+ setPage(1)
221
+ setHasMore(false)
222
+ }, [onOpenChange])
223
+
224
+ const onDialogKeyDown = useCallback((event: ReactKeyboardEvent<HTMLDivElement>) => {
225
+ if (event.key === 'Escape') {
226
+ event.preventDefault()
227
+ onOpenChange(false)
228
+ }
229
+ }, [onOpenChange])
230
+
231
+ // Backend sorting - toggle sort triggers a re-fetch via useEffect
232
+ const toggleSort = useCallback((key: SortState['key']) => {
233
+ setSort((current) => {
234
+ if (current.key === key) {
235
+ return { key, direction: current.direction === 'asc' ? 'desc' : 'asc' }
236
+ }
237
+ // Default to desc for metrics, asc for name
238
+ return { key, direction: key === 'name' ? 'asc' : 'desc' }
239
+ })
240
+ }, [])
241
+
242
+ if (!mounted || !open) return null
243
+
244
+ function normalizeMetricKey(k: string): string {
245
+ if (k.includes('_')) {
246
+ return k.replace(/_([a-z])/g, (_, c) => c.toUpperCase())
247
+ }
248
+ return k
249
+ }
250
+
251
+ // Ensure result item keys follow our camelCase convention
252
+ function normalizeItemKeys(item: ListItem): ListItem {
253
+ const out: Record<string, any> = { ...item }
254
+ for (const key of Object.keys(item)) {
255
+ if (key.includes('_')) {
256
+ const camel = key.replace(/_([a-z])/g, (_, c) => c.toUpperCase())
257
+ if (typeof out[camel] === 'undefined') {
258
+ out[camel] = (item as any)[key]
259
+ }
260
+ }
261
+ }
262
+ return out as ListItem
263
+ }
264
+
265
+ return createPortal(
266
+ <div 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" onClick={handleClose}>
267
+ <div ref={dialogRef} role="dialog" aria-modal="true" tabIndex={-1} 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" onClick={(e) => e.stopPropagation()} onKeyDown={onDialogKeyDown}>
268
+ <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">
269
+ <div>
270
+ <h2 className="text-xl font-semibold text-foreground/90">{title}</h2>
271
+ </div>
272
+ <div className="flex w-full items-center gap-3 sm:w-auto">
273
+ <Input ref={inputRef} placeholder="Press / to search" value={search} onChange={handleSearchChange} className="h-9 w-full sm:w-56" />
274
+ <Button variant="ghost" size="icon" onClick={handleClose} aria-label="Close details dialog">
275
+ <X className="size-5" />
276
+ </Button>
277
+ </div>
278
+ </header>
279
+
280
+ <div className="flex-1 overflow-y-auto p-0 md:p-0">
281
+ <table className="min-w-full table-fixed">
282
+ <thead className="sticky top-0 z-10 bg-background/95">
283
+ <tr>
284
+ <th className="px-6 py-3 text-left text-xs font-semibold uppercase tracking-wide text-foreground/60">
285
+ {sortable ? (
286
+ <button type="button" className="flex items-center gap-1 text-left" onClick={() => toggleSort('name')}>
287
+ {firstColumnLabel}
288
+ <span className="text-[11px] leading-none text-foreground/50">{sort.key === 'name' ? (sort.direction === 'asc' ? '▲' : '▼') : ''}</span>
289
+ </button>
290
+ ) : (
291
+ <span className="flex items-center gap-1 text-left">{firstColumnLabel}</span>
292
+ )}
293
+ </th>
294
+ {metrics.map((metric) => (
295
+ <th key={metric} className="px-6 py-3 text-right text-xs font-semibold uppercase tracking-wide text-foreground/60">
296
+ {sortable ? (
297
+ <button type="button" className="flex w-full items-center justify-end gap-1" onClick={() => toggleSort(metric as SortState['key'])}>
298
+ {metricLabels[metric] ?? METRIC_LABELS[metric] ?? metric}
299
+ <span className="text-[11px] leading-none text-foreground/50">{sort.key === metric ? (sort.direction === 'asc' ? '▲' : '▼') : ''}</span>
300
+ </button>
301
+ ) : (
302
+ <span className="flex w-full items-center justify-end gap-1">{metricLabels[metric] ?? METRIC_LABELS[metric] ?? metric}</span>
303
+ )}
304
+ </th>
305
+ ))}
306
+ </tr>
307
+ </thead>
308
+ <tbody className="divide-y divide-border bg-card text-sm">
309
+ {items.map((item) => (
310
+ <tr key={item.name} className={`transition hover:bg-white/5 ${onRowClick ? 'cursor-pointer' : ''}`} onClick={() => onRowClick?.(item)}>
311
+ <td className="px-6 py-3">
312
+ <div className="flex items-center gap-2">
313
+ {renderLeading ? renderLeading(item) : renderFlag(item)}
314
+ {getExternalLinkUrl ? (
315
+ (() => {
316
+ const href = getExternalLinkUrl(item)
317
+ return href ? (
318
+ <a href={href} target="_blank" rel="noopener noreferrer" className="font-medium text-foreground/90 underline decoration-white/20 hover:decoration-white/40 break-all whitespace-normal">
319
+ {item.name}
320
+ </a>
321
+ ) : (
322
+ <span className="font-medium text-foreground/90">{item.name}</span>
323
+ )
324
+ })()
325
+ ) : (
326
+ <span className="font-medium text-foreground/90">{item.name}</span>
327
+ )}
328
+ </div>
329
+ </td>
330
+ {metrics.map((metric) => (
331
+ <td key={metric} className="px-6 py-3 text-right">
332
+ <span className="tabular-nums text-foreground/80">{formatCell(metric, item[metric])}</span>
333
+ </td>
334
+ ))}
335
+ </tr>
336
+ ))}
337
+ </tbody>
338
+ </table>
339
+ </div>
340
+
341
+ <footer className="flex shrink-0 items-center justify-between border-t border-border px-6 py-3 text-sm md:px-8 md:py-4">
342
+ <div className="text-foreground/60">{loading ? 'Loading…' : hasMore ? 'Scroll for more or click Load More' : 'End of results'}</div>
343
+ {hasMore ? (
344
+ <Button onClick={loadMore} disabled={loading} variant="secondary">Load More</Button>
345
+ ) : null}
346
+ </footer>
347
+ </div>
348
+ </div>,
349
+ document.body
350
+ )
351
+ }
352
+
353
+ function formatCell(metric: string, value: unknown) {
354
+ const formatter = FORMATTERS[metric as ListMetricKey]
355
+ return formatter ? formatter(value as number | null | undefined) : String(value ?? 0)
356
+ }
@@ -0,0 +1,54 @@
1
+ import { createContext, useContext, type ReactNode } from 'react'
2
+
3
+ const TabsContext = createContext<{
4
+ value: string
5
+ onChange: (value: string) => void
6
+ } | null>(null)
7
+
8
+ type TabsProps = {
9
+ value: string
10
+ onValueChange: (value: string) => void
11
+ children: ReactNode
12
+ }
13
+
14
+ export function Tabs({ value, onValueChange, children }: TabsProps) {
15
+ return <TabsContext.Provider value={{ value, onChange: onValueChange }}>{children}</TabsContext.Provider>
16
+ }
17
+
18
+ type TabsListProps = {
19
+ children: ReactNode
20
+ className?: string
21
+ }
22
+
23
+ export function TabsList({ children, className }: TabsListProps) {
24
+ return <div className={['inline-flex items-center rounded-full border bg-muted/60 p-1', className].filter(Boolean).join(' ')}>{children}</div>
25
+ }
26
+
27
+ type TabsTriggerProps = {
28
+ value: string
29
+ children: ReactNode
30
+ className?: string
31
+ }
32
+
33
+ export function TabsTrigger({ value, children, className }: TabsTriggerProps) {
34
+ const context = useContext(TabsContext)
35
+ if (!context) {
36
+ throw new Error('TabsTrigger must be used within Tabs')
37
+ }
38
+ const isActive = context.value === value
39
+ return (
40
+ <button
41
+ type="button"
42
+ onClick={() => context.onChange(value)}
43
+ className={[
44
+ 'rounded-full px-3 py-1 text-xs font-medium transition',
45
+ isActive ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:bg-background',
46
+ className
47
+ ]
48
+ .filter(Boolean)
49
+ .join(' ')}
50
+ >
51
+ {children}
52
+ </button>
53
+ )
54
+ }