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,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
+ }