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,566 @@
|
|
|
1
|
+
import { useCallback, useEffect, useMemo, useState } from 'react'
|
|
2
|
+
import { geoMercator, geoPath } from 'd3-geo'
|
|
3
|
+
import { feature } from 'topojson-client'
|
|
4
|
+
import worldTopology from '@/data/countries-110m.json'
|
|
5
|
+
|
|
6
|
+
import { fetchLocations } from '../api'
|
|
7
|
+
import { useQueryContext } from '../query-context'
|
|
8
|
+
import type { ListItem, ListPayload, MapPayload } from '../types'
|
|
9
|
+
import { useSiteContext } from '../site-context'
|
|
10
|
+
import { MetricTable } from './list-table'
|
|
11
|
+
import { PanelTab, PanelTabs } from './panel-tabs'
|
|
12
|
+
import RemoteDetailsDialog from './remote-details-dialog'
|
|
13
|
+
import { numberShortFormatter } from '../lib/number-formatter'
|
|
14
|
+
import {
|
|
15
|
+
parseDialogFromPath,
|
|
16
|
+
buildDialogPath,
|
|
17
|
+
baseAnalyticsPath,
|
|
18
|
+
locationsSegmentForMode,
|
|
19
|
+
locationsModeForSegment
|
|
20
|
+
} from '../lib/dialog-path'
|
|
21
|
+
import { analyticsPath } from '../lib/base-path'
|
|
22
|
+
import DetailsButton from './details-button'
|
|
23
|
+
|
|
24
|
+
const LOCATION_TABS: Array<{ value: string; label: string }> = [
|
|
25
|
+
{ value: 'map', label: 'Map' },
|
|
26
|
+
{ value: 'countries', label: 'Countries' },
|
|
27
|
+
{ value: 'regions', label: 'Regions' },
|
|
28
|
+
{ value: 'cities', label: 'Cities' }
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
const STORAGE_PREFIX = 'admin.analytics.locations'
|
|
32
|
+
// Vendored TopoJSON to avoid CDN/network issues in dashboards
|
|
33
|
+
// Source: https://cdn.jsdelivr.net/npm/world-atlas@2/countries-110m.json
|
|
34
|
+
const MAP_WIDTH = 720
|
|
35
|
+
// Taller intrinsic viewBox so the SVG grows more vertically relative to its width
|
|
36
|
+
const MAP_HEIGHT = 576 // 5:4 aspect vs old 2:1
|
|
37
|
+
const MAP_MARGIN_X = 12 // horizontal breathing room
|
|
38
|
+
const MAP_MARGIN_Y = 0 // remove vertical padding to maximize map height
|
|
39
|
+
|
|
40
|
+
type LocationsPanelProps = {
|
|
41
|
+
initialData: MapPayload | ListPayload
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
type PanelData = {
|
|
45
|
+
type: 'map'
|
|
46
|
+
payload: MapPayload
|
|
47
|
+
} |
|
|
48
|
+
{
|
|
49
|
+
type: 'list'
|
|
50
|
+
payload: ListPayload
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export default function LocationsPanel({ initialData }: LocationsPanelProps) {
|
|
54
|
+
const { query, updateQuery } = useQueryContext()
|
|
55
|
+
const site = useSiteContext()
|
|
56
|
+
|
|
57
|
+
const [mode, setMode] = useState(() => {
|
|
58
|
+
if (typeof window === 'undefined') {
|
|
59
|
+
return 'map'
|
|
60
|
+
}
|
|
61
|
+
const stored = localStorage.getItem(`${STORAGE_PREFIX}.${site.domain}`)
|
|
62
|
+
return stored && LOCATION_TABS.some((tab) => tab.value === stored) ? stored : 'map'
|
|
63
|
+
})
|
|
64
|
+
const [data, setData] = useState<PanelData>(() =>
|
|
65
|
+
'map' in initialData
|
|
66
|
+
? { type: 'map', payload: initialData as MapPayload }
|
|
67
|
+
: { type: 'list', payload: initialData as ListPayload }
|
|
68
|
+
)
|
|
69
|
+
const [loading, setLoading] = useState(false)
|
|
70
|
+
const [detailsOpen, setDetailsOpen] = useState(false)
|
|
71
|
+
|
|
72
|
+
useEffect(() => {
|
|
73
|
+
const controller = new AbortController()
|
|
74
|
+
setLoading(true)
|
|
75
|
+
fetchLocations(query, { mode }, controller.signal)
|
|
76
|
+
.then((result) => {
|
|
77
|
+
if ('map' in result) {
|
|
78
|
+
setData({ type: 'map', payload: result as MapPayload })
|
|
79
|
+
} else {
|
|
80
|
+
setData({ type: 'list', payload: result as ListPayload })
|
|
81
|
+
}
|
|
82
|
+
})
|
|
83
|
+
.catch((error) => {
|
|
84
|
+
if (error.name !== 'AbortError') console.error(error)
|
|
85
|
+
})
|
|
86
|
+
.finally(() => setLoading(false))
|
|
87
|
+
|
|
88
|
+
return () => controller.abort()
|
|
89
|
+
}, [mode, query])
|
|
90
|
+
|
|
91
|
+
// Deep-link: open Locations dialog for /_/countries, /_/regions, /_/cities
|
|
92
|
+
useEffect(() => {
|
|
93
|
+
const parsed = parseDialogFromPath(window.location.pathname)
|
|
94
|
+
if (parsed.type === 'segment') {
|
|
95
|
+
const modeFromSeg = locationsModeForSegment(parsed.segment)
|
|
96
|
+
if (modeFromSeg) {
|
|
97
|
+
if (mode !== modeFromSeg) setMode(modeFromSeg)
|
|
98
|
+
setDetailsOpen(true)
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}, [])
|
|
102
|
+
|
|
103
|
+
const highlightMetric = useMemo(() => {
|
|
104
|
+
if (data.type === 'list') {
|
|
105
|
+
return data.payload.metrics.includes('visitors') ? 'visitors' : data.payload.metrics[0]
|
|
106
|
+
}
|
|
107
|
+
return 'visitors'
|
|
108
|
+
}, [data])
|
|
109
|
+
|
|
110
|
+
const activeTitle = useMemo(() => {
|
|
111
|
+
switch (mode) {
|
|
112
|
+
case 'regions':
|
|
113
|
+
return 'Regions'
|
|
114
|
+
case 'cities':
|
|
115
|
+
return 'Cities'
|
|
116
|
+
case 'countries':
|
|
117
|
+
case 'map':
|
|
118
|
+
default:
|
|
119
|
+
return 'Countries'
|
|
120
|
+
}
|
|
121
|
+
}, [mode])
|
|
122
|
+
|
|
123
|
+
const firstColumnLabel = useMemo(() => {
|
|
124
|
+
switch (mode) {
|
|
125
|
+
case 'regions':
|
|
126
|
+
return 'Region'
|
|
127
|
+
case 'cities':
|
|
128
|
+
return 'City'
|
|
129
|
+
default:
|
|
130
|
+
return 'Country'
|
|
131
|
+
}
|
|
132
|
+
}, [mode])
|
|
133
|
+
|
|
134
|
+
// Render a country flag for region/city rows when a country filter is active.
|
|
135
|
+
// We intentionally do not attempt per-row geocoding; if no country filter,
|
|
136
|
+
// we omit the flag for regions/cities.
|
|
137
|
+
const renderRegionCityFlag = useCallback(
|
|
138
|
+
(item: ListItem) => {
|
|
139
|
+
// Prefer explicit countryFlag provided by backend (parity with Plausible)
|
|
140
|
+
const explicit = (item as any).countryFlag as string | undefined
|
|
141
|
+
if (explicit && explicit.length <= 6) {
|
|
142
|
+
return <span aria-hidden>{explicit}</span>
|
|
143
|
+
}
|
|
144
|
+
const candidate = String(
|
|
145
|
+
(query.filters && (query.filters as any).country) ||
|
|
146
|
+
(item as any).country ||
|
|
147
|
+
(item as any).alpha2 ||
|
|
148
|
+
(item as any).code ||
|
|
149
|
+
''
|
|
150
|
+
)
|
|
151
|
+
const flag = flagFromIso2(candidate)
|
|
152
|
+
return flag ? <span aria-hidden>{flag}</span> : null
|
|
153
|
+
},
|
|
154
|
+
[query.filters]
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
// Details view now uses a remote modal; build-time list payload no longer needed
|
|
158
|
+
|
|
159
|
+
// Limit card view to top 9 only for list modes; keep map view unchanged
|
|
160
|
+
const limitedListPayload = useMemo(() => {
|
|
161
|
+
if (data.type !== 'list') return null
|
|
162
|
+
const metricKey = data.payload.metrics[0] ?? 'visitors'
|
|
163
|
+
const sorted = [...data.payload.results].sort((a, b) => {
|
|
164
|
+
const av = Number(a[metricKey] ?? 0)
|
|
165
|
+
const bv = Number(b[metricKey] ?? 0)
|
|
166
|
+
if (av === bv) return String(a.name).localeCompare(String(b.name))
|
|
167
|
+
return bv - av
|
|
168
|
+
})
|
|
169
|
+
const sliced = sorted.slice(0, 9)
|
|
170
|
+
return { ...data.payload, metrics: ['visitors'] as any, results: sliced, meta: { ...data.payload.meta, hasMore: data.payload.results.length > 9 } }
|
|
171
|
+
}, [data])
|
|
172
|
+
|
|
173
|
+
const handleCountrySelect = useCallback(
|
|
174
|
+
(countryCode: string, countryLabel?: string) => {
|
|
175
|
+
updateQuery((current) => {
|
|
176
|
+
const next: any = { ...current, filters: { ...current.filters, country: countryCode } }
|
|
177
|
+
if (countryLabel && countryLabel !== countryCode) {
|
|
178
|
+
next.labels = { ...(current.labels || {}), country: countryLabel }
|
|
179
|
+
}
|
|
180
|
+
return next
|
|
181
|
+
})
|
|
182
|
+
setMode('regions')
|
|
183
|
+
if (typeof window !== 'undefined') {
|
|
184
|
+
localStorage.setItem(`${STORAGE_PREFIX}.${site.domain}`, 'regions')
|
|
185
|
+
}
|
|
186
|
+
},
|
|
187
|
+
[site.domain, updateQuery]
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
const handleRegionSelect = useCallback(
|
|
191
|
+
(regionCode: string, regionLabel?: string) => {
|
|
192
|
+
updateQuery((current) => {
|
|
193
|
+
const next: any = { ...current, filters: { ...current.filters, region: regionCode } }
|
|
194
|
+
if (regionLabel && regionLabel !== regionCode) {
|
|
195
|
+
next.labels = { ...(current.labels || {}), region: regionLabel }
|
|
196
|
+
}
|
|
197
|
+
return next
|
|
198
|
+
})
|
|
199
|
+
setMode('cities')
|
|
200
|
+
if (typeof window !== 'undefined') {
|
|
201
|
+
localStorage.setItem(`${STORAGE_PREFIX}.${site.domain}`, 'cities')
|
|
202
|
+
}
|
|
203
|
+
},
|
|
204
|
+
[site.domain, updateQuery]
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
const onDetailsRowClick = useCallback(
|
|
208
|
+
(item: ListItem) => {
|
|
209
|
+
if (mode === 'regions') {
|
|
210
|
+
handleRegionSelect(String(item.code ?? item.name), String(item.name))
|
|
211
|
+
setDetailsOpen(false)
|
|
212
|
+
} else if (mode === 'countries' || mode === 'map') {
|
|
213
|
+
handleCountrySelect(String(item.code ?? item.name), String(item.name))
|
|
214
|
+
setDetailsOpen(false)
|
|
215
|
+
}
|
|
216
|
+
},
|
|
217
|
+
[handleCountrySelect, handleRegionSelect, mode]
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
return (
|
|
221
|
+
<section className={`flex flex-col ${mode === 'map' ? 'gap-0' : 'gap-5'} rounded-xl border border-border bg-card p-5 shadow-[0_12px_26px_rgba(7,9,16,0.32)]`} data-testid="locations-panel">
|
|
222
|
+
<header className="flex flex-wrap items-center justify-between gap-3">
|
|
223
|
+
<h2 className="text-lg/6 font-semibold text-foreground/80">{activeTitle}</h2>
|
|
224
|
+
<PanelTabs>
|
|
225
|
+
{LOCATION_TABS.map((tab) => (
|
|
226
|
+
<PanelTab
|
|
227
|
+
key={tab.value}
|
|
228
|
+
active={mode === tab.value}
|
|
229
|
+
onClick={() => {
|
|
230
|
+
setMode(tab.value)
|
|
231
|
+
localStorage.setItem(`${STORAGE_PREFIX}.${site.domain}`, tab.value)
|
|
232
|
+
}}
|
|
233
|
+
>
|
|
234
|
+
{tab.label}
|
|
235
|
+
</PanelTab>
|
|
236
|
+
))}
|
|
237
|
+
</PanelTabs>
|
|
238
|
+
</header>
|
|
239
|
+
|
|
240
|
+
{loading ? (
|
|
241
|
+
<div className="flex h-48 items-center justify-center text-sm text-muted-foreground">Loading…</div>
|
|
242
|
+
) : data.type === 'map' ? (
|
|
243
|
+
<>
|
|
244
|
+
<CountriesMap data={data.payload} onSelectCountry={handleCountrySelect} />
|
|
245
|
+
<div className="flex justify-center pt-0">
|
|
246
|
+
<DetailsButton onClick={() => setDetailsOpen(true)}>Details</DetailsButton>
|
|
247
|
+
</div>
|
|
248
|
+
</>
|
|
249
|
+
) : (
|
|
250
|
+
<>
|
|
251
|
+
{data.payload.results.length === 0 ? (
|
|
252
|
+
<div className="flex h-40 items-center justify-center text-sm text-muted-foreground">No data yet</div>
|
|
253
|
+
) : (
|
|
254
|
+
<MetricTable
|
|
255
|
+
data={limitedListPayload ?? (data as Extract<PanelData, { type: 'list' }>).payload}
|
|
256
|
+
highlightedMetric={highlightMetric ?? 'visitors'}
|
|
257
|
+
onRowClick={(item) => {
|
|
258
|
+
if (mode === 'regions') {
|
|
259
|
+
handleRegionSelect(String(item.code ?? item.name), String(item.name))
|
|
260
|
+
} else if (mode === 'countries') {
|
|
261
|
+
handleCountrySelect(String(item.code ?? item.name), String(item.name))
|
|
262
|
+
} else if (mode === 'cities') {
|
|
263
|
+
updateQuery((current) => ({
|
|
264
|
+
...current,
|
|
265
|
+
filters: { ...current.filters, city: String(item.name) },
|
|
266
|
+
labels: { ...(current.labels || {}), city: String(item.name) }
|
|
267
|
+
}))
|
|
268
|
+
}
|
|
269
|
+
}}
|
|
270
|
+
renderLeading={(mode === 'regions' || mode === 'cities') ? renderRegionCityFlag : undefined}
|
|
271
|
+
displayBars={false}
|
|
272
|
+
firstColumnLabel={firstColumnLabel}
|
|
273
|
+
barColorTheme="cyan"
|
|
274
|
+
testId="locations"
|
|
275
|
+
/>
|
|
276
|
+
)}
|
|
277
|
+
<div className="mt-auto flex justify-center pt-3">
|
|
278
|
+
<DetailsButton data-testid="locations-details-btn" onClick={() => {
|
|
279
|
+
setDetailsOpen(true)
|
|
280
|
+
try {
|
|
281
|
+
const sp = new URLSearchParams(window.location.search)
|
|
282
|
+
sp.delete('dialog'); sp.delete('mode')
|
|
283
|
+
const seg = locationsSegmentForMode(mode as 'map' | 'countries' | 'regions' | 'cities')
|
|
284
|
+
window.history.pushState({}, '', buildDialogPath(seg, sp.toString()))
|
|
285
|
+
} catch {}
|
|
286
|
+
}}>Details</DetailsButton>
|
|
287
|
+
</div>
|
|
288
|
+
</>
|
|
289
|
+
)}
|
|
290
|
+
|
|
291
|
+
{
|
|
292
|
+
<RemoteDetailsDialog
|
|
293
|
+
open={detailsOpen}
|
|
294
|
+
onOpenChange={(open) => {
|
|
295
|
+
setDetailsOpen(open)
|
|
296
|
+
try {
|
|
297
|
+
const sp = new URLSearchParams(window.location.search)
|
|
298
|
+
sp.delete('dialog'); sp.delete('mode')
|
|
299
|
+
const qs = sp.toString()
|
|
300
|
+
if (open) {
|
|
301
|
+
const seg = locationsSegmentForMode(mode as 'map' | 'countries' | 'regions' | 'cities')
|
|
302
|
+
window.history.pushState({}, '', buildDialogPath(seg, qs))
|
|
303
|
+
} else {
|
|
304
|
+
window.history.pushState({}, '', baseAnalyticsPath(qs))
|
|
305
|
+
}
|
|
306
|
+
} catch {}
|
|
307
|
+
}}
|
|
308
|
+
title={`Top ${activeTitle}`}
|
|
309
|
+
endpoint={analyticsPath('locations')}
|
|
310
|
+
extras={{ mode: (mode === 'map' ? 'countries' : mode) }}
|
|
311
|
+
firstColumnLabel={firstColumnLabel}
|
|
312
|
+
renderLeading={(mode === 'regions' || mode === 'cities') ? renderRegionCityFlag : undefined}
|
|
313
|
+
defaultSortKey={'visitors'}
|
|
314
|
+
onRowClick={(item) => {
|
|
315
|
+
if (mode === 'cities') {
|
|
316
|
+
updateQuery((current) => {
|
|
317
|
+
const cityName = String(item.name)
|
|
318
|
+
const next: any = { ...current, filters: { ...current.filters, city: cityName } }
|
|
319
|
+
if (current.labels?.city !== cityName) {
|
|
320
|
+
next.labels = { ...(current.labels || {}), city: cityName }
|
|
321
|
+
}
|
|
322
|
+
return next
|
|
323
|
+
})
|
|
324
|
+
setDetailsOpen(false)
|
|
325
|
+
} else {
|
|
326
|
+
onDetailsRowClick(item)
|
|
327
|
+
}
|
|
328
|
+
}}
|
|
329
|
+
/>
|
|
330
|
+
}
|
|
331
|
+
</section>
|
|
332
|
+
)
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
type CountriesMapProps = {
|
|
336
|
+
data: MapPayload
|
|
337
|
+
onSelectCountry: (isoCode: string, label?: string) => void
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
type GeoFeature = any
|
|
341
|
+
|
|
342
|
+
function CountriesMap({ data, onSelectCountry }: CountriesMapProps) {
|
|
343
|
+
const [features, setFeatures] = useState<GeoFeature[]>([])
|
|
344
|
+
const [tooltip, setTooltip] = useState<{
|
|
345
|
+
x: number
|
|
346
|
+
y: number
|
|
347
|
+
name: string
|
|
348
|
+
flag?: string | null
|
|
349
|
+
visitors: number
|
|
350
|
+
width: number
|
|
351
|
+
height: number
|
|
352
|
+
} | null>(null)
|
|
353
|
+
|
|
354
|
+
useEffect(() => {
|
|
355
|
+
// Build features from local TopoJSON (no network required)
|
|
356
|
+
try {
|
|
357
|
+
const topology: any = worldTopology as any
|
|
358
|
+
const collection = feature(topology, topology.objects.countries) as unknown as { features: GeoFeature[] }
|
|
359
|
+
const filtered = collection.features.filter((f) => {
|
|
360
|
+
const id = String((f as any).id)
|
|
361
|
+
const name = String((f as any).properties?.name || (f as any).properties?.NAME || (f as any).properties?.ADMIN || '')
|
|
362
|
+
if (id === '010') return false // Antarctica ISO numeric code
|
|
363
|
+
if (/antarctica/i.test(name)) return false
|
|
364
|
+
return true
|
|
365
|
+
})
|
|
366
|
+
setFeatures(filtered)
|
|
367
|
+
} catch (error) {
|
|
368
|
+
console.error('Failed to prepare map features', error)
|
|
369
|
+
}
|
|
370
|
+
}, [])
|
|
371
|
+
|
|
372
|
+
const lookup = useMemo(() => {
|
|
373
|
+
const map = new Map<string, { visitors: number; code?: string; name: string }>()
|
|
374
|
+
data.map.results.forEach((entry) => {
|
|
375
|
+
const record = {
|
|
376
|
+
visitors: entry.visitors,
|
|
377
|
+
code: entry.code?.toUpperCase(),
|
|
378
|
+
name: entry.name
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// Map by numeric code (used by TopoJSON)
|
|
382
|
+
if (entry.numeric) {
|
|
383
|
+
map.set(entry.numeric, record)
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// Also map by alpha3 and alpha2 for compatibility
|
|
387
|
+
const alpha3 = entry.alpha3?.toUpperCase()
|
|
388
|
+
if (alpha3) {
|
|
389
|
+
map.set(alpha3, record)
|
|
390
|
+
}
|
|
391
|
+
const alpha2 = entry.alpha2?.toUpperCase()
|
|
392
|
+
if (alpha2) {
|
|
393
|
+
map.set(alpha2, record)
|
|
394
|
+
}
|
|
395
|
+
})
|
|
396
|
+
return map
|
|
397
|
+
}, [data])
|
|
398
|
+
|
|
399
|
+
// Build a projection that always fits the loaded features with a small margin
|
|
400
|
+
const projection = useMemo(() => {
|
|
401
|
+
const p = geoMercator()
|
|
402
|
+
if (features.length > 0) {
|
|
403
|
+
const fc = { type: 'FeatureCollection', features } as any
|
|
404
|
+
return p.fitExtent(
|
|
405
|
+
[[MAP_MARGIN_X, MAP_MARGIN_Y], [MAP_WIDTH - MAP_MARGIN_X, MAP_HEIGHT - MAP_MARGIN_Y]],
|
|
406
|
+
fc
|
|
407
|
+
)
|
|
408
|
+
}
|
|
409
|
+
// Sensible fallback before features load (same aspect as viewBox)
|
|
410
|
+
return p
|
|
411
|
+
.scale((MAP_WIDTH - 2 * MAP_MARGIN_X) / (2 * Math.PI))
|
|
412
|
+
.translate([MAP_WIDTH / 2, MAP_HEIGHT / 2])
|
|
413
|
+
}, [features])
|
|
414
|
+
const pathGenerator = useMemo(() => geoPath(projection), [projection])
|
|
415
|
+
const max = Math.max(...Array.from(lookup.values()).map((value) => value.visitors), 1)
|
|
416
|
+
|
|
417
|
+
return (
|
|
418
|
+
<div className="relative rounded-xs bg-card">
|
|
419
|
+
<svg
|
|
420
|
+
role="img"
|
|
421
|
+
aria-label="World map highlighting visitor distribution"
|
|
422
|
+
viewBox={`0 0 ${MAP_WIDTH} ${MAP_HEIGHT}`}
|
|
423
|
+
className="w-full h-auto"
|
|
424
|
+
preserveAspectRatio="xMidYMid meet"
|
|
425
|
+
>
|
|
426
|
+
<g>
|
|
427
|
+
{features.map((featureItem) => {
|
|
428
|
+
// Try numeric ID first (TopoJSON uses ISO 3166-1 numeric codes)
|
|
429
|
+
const numericId = String(featureItem.id)
|
|
430
|
+
const alpha3Candidate = featureItem.properties?.ISO_A3
|
|
431
|
+
const iso2Candidate = featureItem.properties?.ISO_A2
|
|
432
|
+
|
|
433
|
+
const record =
|
|
434
|
+
lookup.get(numericId) ||
|
|
435
|
+
(typeof alpha3Candidate === 'string' && lookup.get(alpha3Candidate.toUpperCase())) ||
|
|
436
|
+
(typeof iso2Candidate === 'string' && lookup.get(iso2Candidate.toUpperCase()))
|
|
437
|
+
|
|
438
|
+
const intensity = record ? record.visitors / max : 0
|
|
439
|
+
// Use unified accent ramp for filled countries
|
|
440
|
+
const fill = record
|
|
441
|
+
? colorForIntensity(intensity)
|
|
442
|
+
: 'color-mix(in oklch, var(--foreground) 12%, transparent)'
|
|
443
|
+
const stroke = record
|
|
444
|
+
? 'color-mix(in oklch, var(--foreground) 28%, transparent)'
|
|
445
|
+
: 'color-mix(in oklch, var(--foreground) 22%, transparent)'
|
|
446
|
+
const path = pathGenerator(featureItem)
|
|
447
|
+
if (!path) return null
|
|
448
|
+
|
|
449
|
+
return (
|
|
450
|
+
<path
|
|
451
|
+
key={(typeof alpha3Candidate === 'string' ? alpha3Candidate : iso2Candidate) ?? path}
|
|
452
|
+
d={path}
|
|
453
|
+
fill={fill}
|
|
454
|
+
stroke={stroke}
|
|
455
|
+
strokeWidth={record ? 1 : 0.5}
|
|
456
|
+
className="cursor-pointer transition-all duration-150 hover:brightness-110"
|
|
457
|
+
onClick={() => {
|
|
458
|
+
if (record) {
|
|
459
|
+
onSelectCountry(record.code ?? String(alpha3Candidate ?? iso2Candidate), record.name)
|
|
460
|
+
}
|
|
461
|
+
}}
|
|
462
|
+
onMouseMove={(event) => {
|
|
463
|
+
if (!record) {
|
|
464
|
+
setTooltip(null)
|
|
465
|
+
return
|
|
466
|
+
}
|
|
467
|
+
const bounds = event.currentTarget.ownerSVGElement?.getBoundingClientRect()
|
|
468
|
+
if (!bounds) return
|
|
469
|
+
const pretty = prettifyCountryName(record.name)
|
|
470
|
+
const flag = flagFromIso2(record.code ?? String(iso2Candidate ?? '')) || null
|
|
471
|
+
setTooltip({
|
|
472
|
+
name: pretty,
|
|
473
|
+
flag,
|
|
474
|
+
visitors: record.visitors,
|
|
475
|
+
x: event.clientX - bounds.left,
|
|
476
|
+
y: event.clientY - bounds.top,
|
|
477
|
+
width: bounds.width,
|
|
478
|
+
height: bounds.height
|
|
479
|
+
})
|
|
480
|
+
}}
|
|
481
|
+
onMouseLeave={() => setTooltip(null)}
|
|
482
|
+
/>
|
|
483
|
+
)
|
|
484
|
+
})}
|
|
485
|
+
</g>
|
|
486
|
+
</svg>
|
|
487
|
+
{tooltip ? (
|
|
488
|
+
<div
|
|
489
|
+
className="pointer-events-none absolute z-50"
|
|
490
|
+
style={{
|
|
491
|
+
left: Math.min(tooltip.x + 12, tooltip.width - 200),
|
|
492
|
+
top: Math.min(tooltip.y + 12, tooltip.height - 72),
|
|
493
|
+
// Match the dark chart tooltip shell
|
|
494
|
+
background: 'rgba(17, 19, 27, 0.95)',
|
|
495
|
+
border: '1px solid rgba(255, 255, 255, 0.12)',
|
|
496
|
+
borderRadius: '10px',
|
|
497
|
+
padding: '8px 10px',
|
|
498
|
+
color: 'rgba(255,255,255,0.9)',
|
|
499
|
+
minWidth: '160px',
|
|
500
|
+
boxShadow: '0 8px 24px rgba(0,0,0,0.35)'
|
|
501
|
+
}}
|
|
502
|
+
>
|
|
503
|
+
<div className="mb-1 flex items-center gap-1.5">
|
|
504
|
+
{tooltip.flag ? (
|
|
505
|
+
<span aria-hidden className="shrink-0" style={{ fontSize: '14px', lineHeight: '18px' }}>
|
|
506
|
+
{tooltip.flag}
|
|
507
|
+
</span>
|
|
508
|
+
) : null}
|
|
509
|
+
<p
|
|
510
|
+
className="truncate font-extrabold"
|
|
511
|
+
style={{ fontSize: '14px', lineHeight: '18px', color: 'rgba(255,255,255,0.92)' }}
|
|
512
|
+
>
|
|
513
|
+
{tooltip.name}
|
|
514
|
+
</p>
|
|
515
|
+
</div>
|
|
516
|
+
<div className="flex items-baseline gap-1.5">
|
|
517
|
+
<span
|
|
518
|
+
className="font-extrabold tabular-nums"
|
|
519
|
+
style={{ fontSize: '18px', lineHeight: '22px', color: 'rgba(255,255,255,0.94)' }}
|
|
520
|
+
>
|
|
521
|
+
{numberShortFormatter(tooltip.visitors)}
|
|
522
|
+
</span>
|
|
523
|
+
<span style={{ fontSize: '13px', color: 'rgba(255,255,255,0.65)' }}>
|
|
524
|
+
Visitors
|
|
525
|
+
</span>
|
|
526
|
+
</div>
|
|
527
|
+
</div>
|
|
528
|
+
) : null}
|
|
529
|
+
</div>
|
|
530
|
+
)
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
function colorForIntensity(value: number) {
|
|
534
|
+
// Sequential ramp in the single accent family (cyan)
|
|
535
|
+
// Lower intensities: softer tint + more transparency
|
|
536
|
+
// Higher intensities: richer tint + higher opacity
|
|
537
|
+
const clamped = Math.min(Math.max(value, 0.08), 1)
|
|
538
|
+
const tint = Math.round(20 + clamped * 60) // 20%..80% var(--accent) toward white
|
|
539
|
+
const alpha = Math.round(18 + clamped * 60) // 18%..78% vs transparent
|
|
540
|
+
const hue = `color-mix(in oklch, var(--data-accent) ${tint}%, white)`
|
|
541
|
+
return `color-mix(in oklch, ${hue} ${alpha}%, transparent)`
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
// Emoji flag from ISO 3166-1 alpha-2
|
|
545
|
+
function flagFromIso2(code?: string) {
|
|
546
|
+
if (!code) return ''
|
|
547
|
+
const iso2 = code.toUpperCase()
|
|
548
|
+
if (!/^[A-Z]{2}$/.test(iso2)) return ''
|
|
549
|
+
const A = 0x1f1e6 // regional indicator 'A'
|
|
550
|
+
const chars = Array.from(iso2).map((c) => String.fromCodePoint(A + (c.charCodeAt(0) - 65)))
|
|
551
|
+
return chars.join('')
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
// Prefer short, user-friendly country names for UI tooltips
|
|
555
|
+
function prettifyCountryName(name: string): string {
|
|
556
|
+
const str = String(name || '')
|
|
557
|
+
const direct: Record<string, string> = {
|
|
558
|
+
'United States of America (the)': 'United States',
|
|
559
|
+
'United States of America': 'United States',
|
|
560
|
+
'Viet Nam': 'Vietnam'
|
|
561
|
+
}
|
|
562
|
+
if (direct[str]) return direct[str]
|
|
563
|
+
// Trim trailing " (the)"
|
|
564
|
+
const cleaned = str.replace(/\s*\(the\)\s*$/i, '').trim()
|
|
565
|
+
return cleaned
|
|
566
|
+
}
|