ahoy_analytics 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/MIT-LICENSE +20 -0
- data/README.md +163 -0
- data/Rakefile +6 -0
- data/app/assets/ahoy_analytics/build/assets/Combination-BpSXUjp9.js +41 -0
- data/app/assets/ahoy_analytics/build/assets/analytics-5KyfCxh6.css +1 -0
- data/app/assets/ahoy_analytics/build/assets/analytics-dashboard-uOXx8zYZ.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/analytics-layout-ClAft5OU.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/analytics-tracker-B3f8P98z.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/analytics-ui-DMSkNqd6.js +90 -0
- data/app/assets/ahoy_analytics/build/assets/behaviors-panel-ChNGYbdH.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/button-JVCrlR4s.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/cable-DO-7y1-E.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/createLucideIcon-BGzacY2v.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/date-range-dialog-DWDp3cLG.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/details-button-NqKfSGEG.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/devices-panel-cXvlmNBY.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/dialog-path-BBPNlB4Z.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/dropdown-menu-Adj3O5fh.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/filter-dialog-BN-rf4lp.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/index-B1K1NTKT.js +3 -0
- data/app/assets/ahoy_analytics/build/assets/index-BcHeb-Rh.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/index-DzpzLoG4.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/index-vX97OY1J.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/input-e4v_v0kE.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/jsx-runtime-u17CrQMm.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/last-load-context-De5uA95L.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/list-table-ChHEzzF9.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/live-Cp2MHECh.js +2 -0
- data/app/assets/ahoy_analytics/build/assets/locations-panel-BaISRmaQ.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/mercator-BnxX5RzL.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/pages-panel-Bh25L8mP.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/panel-tabs-B2kvGFJx.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/query-context-B-PgE00D.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/remote-details-dialog-DDTcKaM5.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/show-CCRicksg.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/simple-tabs-D6G6Bs0k.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/site-context-BNteYRlR.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/sources-panel-DyB21hxD.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/top-bar-FSiLBjq6.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/top-stats-context-DU15P9jS.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/use-debounce-VBpXQRL8.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/user-context-DbYteluY.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/visitor-globe-BWLDihid.js +4789 -0
- data/app/assets/ahoy_analytics/build/assets/visitor-graph-uKXjLvcu.js +1 -0
- data/app/assets/ahoy_analytics/images/icon/browser/brave.svg +1 -0
- data/app/assets/ahoy_analytics/images/icon/browser/chrome.svg +1 -0
- data/app/assets/ahoy_analytics/images/icon/browser/chromium.svg +1 -0
- data/app/assets/ahoy_analytics/images/icon/browser/duckduckgo.svg +2151 -0
- data/app/assets/ahoy_analytics/images/icon/browser/edge.svg +1 -0
- data/app/assets/ahoy_analytics/images/icon/browser/fallback.svg +5 -0
- data/app/assets/ahoy_analytics/images/icon/browser/firefox.svg +1 -0
- data/app/assets/ahoy_analytics/images/icon/browser/opera.svg +1 -0
- data/app/assets/ahoy_analytics/images/icon/browser/safari.png +0 -0
- data/app/assets/ahoy_analytics/images/icon/browser/samsung-internet.svg +1 -0
- data/app/assets/ahoy_analytics/images/icon/browser/uc.svg +1 -0
- data/app/assets/ahoy_analytics/images/icon/browser/vivaldi.svg +1 -0
- data/app/assets/ahoy_analytics/images/icon/browser/yandex.png +0 -0
- data/app/assets/ahoy_analytics/images/icon/os/android.png +0 -0
- data/app/assets/ahoy_analytics/images/icon/os/chrome_os.png +0 -0
- data/app/assets/ahoy_analytics/images/icon/os/fallback.svg +5 -0
- data/app/assets/ahoy_analytics/images/icon/os/fedora.png +0 -0
- data/app/assets/ahoy_analytics/images/icon/os/freebsd.png +0 -0
- data/app/assets/ahoy_analytics/images/icon/os/gnu_linux.png +0 -0
- data/app/assets/ahoy_analytics/images/icon/os/ios.png +0 -0
- data/app/assets/ahoy_analytics/images/icon/os/ipad_os.png +0 -0
- data/app/assets/ahoy_analytics/images/icon/os/mac.png +0 -0
- data/app/assets/ahoy_analytics/images/icon/os/ubuntu.png +0 -0
- data/app/assets/ahoy_analytics/images/icon/os/windows.png +0 -0
- data/app/assets/stylesheets/ahoy_analytics/application.css +15 -0
- data/app/channels/ahoy_analytics/analytics_channel.rb +9 -0
- data/app/controllers/ahoy_analytics/analytics_controller.rb +9 -0
- data/app/controllers/ahoy_analytics/application_controller.rb +8 -0
- data/app/controllers/ahoy_analytics/assets_controller.rb +46 -0
- data/app/controllers/ahoy_analytics/base_controller.rb +285 -0
- data/app/controllers/ahoy_analytics/behaviors_controller.rb +14 -0
- data/app/controllers/ahoy_analytics/devices_controller.rb +14 -0
- data/app/controllers/ahoy_analytics/export_controller.rb +12 -0
- data/app/controllers/ahoy_analytics/live_controller.rb +9 -0
- data/app/controllers/ahoy_analytics/locations_controller.rb +14 -0
- data/app/controllers/ahoy_analytics/main_graph_controller.rb +10 -0
- data/app/controllers/ahoy_analytics/pages_controller.rb +14 -0
- data/app/controllers/ahoy_analytics/referrers_controller.rb +34 -0
- data/app/controllers/ahoy_analytics/search_terms_controller.rb +47 -0
- data/app/controllers/ahoy_analytics/sources_controller.rb +14 -0
- data/app/controllers/ahoy_analytics/top_stats_controller.rb +13 -0
- data/app/controllers/concerns/ahoy_analytics/set_current_request.rb +17 -0
- data/app/frontend/components/analytics/hex-highlights.tsx +165 -0
- data/app/frontend/components/analytics/hex-land-layer.tsx +61 -0
- data/app/frontend/components/analytics/metric-card.tsx +138 -0
- data/app/frontend/components/analytics/sessions-by-location.tsx +62 -0
- data/app/frontend/components/analytics/visitor-globe.tsx +424 -0
- data/app/frontend/components/ui/accordion.tsx +64 -0
- data/app/frontend/components/ui/alert.tsx +66 -0
- data/app/frontend/components/ui/avatar.tsx +53 -0
- data/app/frontend/components/ui/badge.tsx +46 -0
- data/app/frontend/components/ui/button.tsx +62 -0
- data/app/frontend/components/ui/calendar.tsx +212 -0
- data/app/frontend/components/ui/card.tsx +91 -0
- data/app/frontend/components/ui/checkbox.tsx +32 -0
- data/app/frontend/components/ui/dropdown-menu.tsx +255 -0
- data/app/frontend/components/ui/input.tsx +21 -0
- data/app/frontend/components/ui/label.tsx +22 -0
- data/app/frontend/components/ui/popover.tsx +46 -0
- data/app/frontend/components/ui/select.tsx +183 -0
- data/app/frontend/components/ui/separator.tsx +26 -0
- data/app/frontend/components/ui/sheet.tsx +139 -0
- data/app/frontend/components/ui/sidebar.tsx +726 -0
- data/app/frontend/components/ui/skeleton.tsx +13 -0
- data/app/frontend/components/ui/sonner.tsx +33 -0
- data/app/frontend/components/ui/tooltip.tsx +59 -0
- data/app/frontend/data/countries-110m.json +1 -0
- data/app/frontend/data/globe-data.json +1 -0
- data/app/frontend/entrypoints/analytics-tracker.ts +680 -0
- data/app/frontend/entrypoints/analytics-ui.tsx +26 -0
- data/app/frontend/entrypoints/analytics.css +77 -0
- data/app/frontend/layouts/analytics-layout.tsx +28 -0
- data/app/frontend/lib/cable.ts +13 -0
- data/app/frontend/lib/geocode.ts +65 -0
- data/app/frontend/lib/utils.ts +6 -0
- data/app/frontend/pages/admin/analytics/api.ts +221 -0
- data/app/frontend/pages/admin/analytics/hooks/use-debounce.ts +36 -0
- data/app/frontend/pages/admin/analytics/last-load-context.tsx +29 -0
- data/app/frontend/pages/admin/analytics/lib/base-path.ts +28 -0
- data/app/frontend/pages/admin/analytics/lib/dialog-path.ts +242 -0
- data/app/frontend/pages/admin/analytics/lib/number-formatter.ts +100 -0
- data/app/frontend/pages/admin/analytics/live.tsx +608 -0
- data/app/frontend/pages/admin/analytics/query-context.tsx +61 -0
- data/app/frontend/pages/admin/analytics/show.tsx +40 -0
- data/app/frontend/pages/admin/analytics/site-context.tsx +22 -0
- data/app/frontend/pages/admin/analytics/top-stats-context.tsx +37 -0
- data/app/frontend/pages/admin/analytics/types.ts +161 -0
- data/app/frontend/pages/admin/analytics/ui/analytics-dashboard.tsx +60 -0
- data/app/frontend/pages/admin/analytics/ui/behaviors-panel.tsx +456 -0
- data/app/frontend/pages/admin/analytics/ui/date-range-dialog.tsx +173 -0
- data/app/frontend/pages/admin/analytics/ui/details-button.tsx +33 -0
- data/app/frontend/pages/admin/analytics/ui/devices-panel.tsx +474 -0
- data/app/frontend/pages/admin/analytics/ui/filter-dialog.tsx +558 -0
- data/app/frontend/pages/admin/analytics/ui/list-table.tsx +346 -0
- data/app/frontend/pages/admin/analytics/ui/locations-panel.tsx +566 -0
- data/app/frontend/pages/admin/analytics/ui/pages-panel.tsx +207 -0
- data/app/frontend/pages/admin/analytics/ui/panel-tabs.tsx +65 -0
- data/app/frontend/pages/admin/analytics/ui/remote-details-dialog.tsx +356 -0
- data/app/frontend/pages/admin/analytics/ui/simple-tabs.tsx +54 -0
- data/app/frontend/pages/admin/analytics/ui/sources-panel.tsx +771 -0
- data/app/frontend/pages/admin/analytics/ui/top-bar.tsx +793 -0
- data/app/frontend/pages/admin/analytics/ui/visitor-graph.tsx +891 -0
- data/app/frontend/pages/admin/analytics/user-context.tsx +22 -0
- data/app/frontend/styles/shared.css +156 -0
- data/app/helpers/ahoy_analytics/application_helper.rb +96 -0
- data/app/jobs/ahoy_analytics/application_job.rb +4 -0
- data/app/jobs/ahoy_analytics/update_job.rb +12 -0
- data/app/mailers/ahoy_analytics/application_mailer.rb +6 -0
- data/app/models/ahoy/event/filters.rb +7 -0
- data/app/models/ahoy/event.rb +9 -0
- data/app/models/ahoy/visit/cache_key.rb +15 -0
- data/app/models/ahoy/visit/constants.rb +11 -0
- data/app/models/ahoy/visit/devices.rb +144 -0
- data/app/models/ahoy/visit/export.rb +24 -0
- data/app/models/ahoy/visit/filters.rb +286 -0
- data/app/models/ahoy/visit/imports.rb +36 -0
- data/app/models/ahoy/visit/locations.rb +276 -0
- data/app/models/ahoy/visit/metrics.rb +473 -0
- data/app/models/ahoy/visit/ordering.rb +110 -0
- data/app/models/ahoy/visit/pages.rb +533 -0
- data/app/models/ahoy/visit/pagination.rb +17 -0
- data/app/models/ahoy/visit/ranges.rb +227 -0
- data/app/models/ahoy/visit/series.rb +177 -0
- data/app/models/ahoy/visit/sources.rb +418 -0
- data/app/models/ahoy/visit/url_labels.rb +32 -0
- data/app/models/ahoy/visit.rb +143 -0
- data/app/models/ahoy_analytics/application_record.rb +5 -0
- data/app/models/ahoy_analytics/current.rb +8 -0
- data/app/models/ahoy_analytics/funnel.rb +16 -0
- data/app/models/ahoy_analytics/imported_entry_page.rb +5 -0
- data/app/models/ahoy_analytics/imported_exit_page.rb +5 -0
- data/app/models/ahoy_analytics/imported_page.rb +5 -0
- data/app/models/ahoy_analytics/live_stats.rb +152 -0
- data/app/models/ahoy_analytics/setting.rb +19 -0
- data/app/models/analytics/source_catalog.rb +48 -0
- data/app/views/layouts/ahoy_analytics/application.html.erb +15 -0
- data/config/routes.rb +21 -0
- data/config/vite.json +22 -0
- data/db/migrate/20251006104056_create_ahoy_visits_and_events.rb +62 -0
- data/db/migrate/20251006105012_add_analytics_fields_to_ahoy_visits.rb +11 -0
- data/db/migrate/20251012090000_create_analytics_funnels_and_imports.rb +52 -0
- data/db/migrate/20251013021500_add_analytics_indexes.rb +14 -0
- data/lib/ahoy_analytics/ahoy_store.rb +429 -0
- data/lib/ahoy_analytics/asset_manifest.rb +56 -0
- data/lib/ahoy_analytics/device_bucket.rb +39 -0
- data/lib/ahoy_analytics/engine.rb +55 -0
- data/lib/ahoy_analytics/maxmind_geo.rb +77 -0
- data/lib/ahoy_analytics/version.rb +3 -0
- data/lib/ahoy_analytics.rb +52 -0
- data/lib/generators/ahoy_analytics/install/install_generator.rb +111 -0
- data/lib/generators/ahoy_analytics/install/templates/initializer.rb +28 -0
- data/lib/tasks/ahoy_analytics_tasks.rake +4 -0
- metadata +352 -0
|
@@ -0,0 +1,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
|
+
}
|