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,771 @@
|
|
|
1
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
2
|
+
|
|
3
|
+
import { fetchSources, fetchReferrers, fetchSearchTerms } from '../api'
|
|
4
|
+
import { useQueryContext } from '../query-context'
|
|
5
|
+
import { MetricTable } from './list-table'
|
|
6
|
+
import type { ListItem, ListPayload, ListMetricKey } from '../types'
|
|
7
|
+
import { useSiteContext } from '../site-context'
|
|
8
|
+
import RemoteDetailsDialog from './remote-details-dialog'
|
|
9
|
+
import DetailsButton from './details-button'
|
|
10
|
+
import { PanelTab, PanelTabDropdown, PanelTabs } from './panel-tabs'
|
|
11
|
+
import {
|
|
12
|
+
parseDialogFromPath,
|
|
13
|
+
buildDialogPath,
|
|
14
|
+
baseAnalyticsPath,
|
|
15
|
+
buildReferrersPath,
|
|
16
|
+
dialogSegmentForMode,
|
|
17
|
+
modeForSegment
|
|
18
|
+
} from '../lib/dialog-path'
|
|
19
|
+
import { analyticsPath } from '../lib/base-path'
|
|
20
|
+
|
|
21
|
+
const CAMPAIGN_OPTIONS: Array<{ value: string; label: string }> = [
|
|
22
|
+
{ value: 'utm-medium', label: 'UTM Mediums' },
|
|
23
|
+
{ value: 'utm-source', label: 'UTM Sources' },
|
|
24
|
+
{ value: 'utm-campaign', label: 'UTM Campaigns' },
|
|
25
|
+
{ value: 'utm-content', label: 'UTM Contents' },
|
|
26
|
+
{ value: 'utm-term', label: 'UTM Terms' }
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
const ALLOWED_MODES = ['channels', 'all', ...CAMPAIGN_OPTIONS.map((option) => option.value)]
|
|
30
|
+
|
|
31
|
+
const TITLE_FOR_MODE: Record<string, string> = {
|
|
32
|
+
channels: 'Top Channels',
|
|
33
|
+
all: 'Top Sources',
|
|
34
|
+
'utm-medium': 'UTM Mediums',
|
|
35
|
+
'utm-source': 'UTM Sources',
|
|
36
|
+
'utm-campaign': 'UTM Campaigns',
|
|
37
|
+
'utm-content': 'UTM Contents',
|
|
38
|
+
'utm-term': 'UTM Terms'
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const STORAGE_PREFIX = 'admin.analytics.sources'
|
|
42
|
+
|
|
43
|
+
// Favicon domain mapping - matches Plausible's referer_favicon_domains.json
|
|
44
|
+
// Maps categorized source names to their primary domains for favicon fetching
|
|
45
|
+
const FAVICON_DOMAIN_MAP: Record<string, string> = {
|
|
46
|
+
'Google': 'google.com',
|
|
47
|
+
'Bing': 'bing.com',
|
|
48
|
+
'DuckDuckGo': 'duckduckgo.com',
|
|
49
|
+
'Yahoo!': 'yahoo.com',
|
|
50
|
+
'Yahoo! Mail': 'mail.yahoo.com',
|
|
51
|
+
'Baidu': 'baidu.com',
|
|
52
|
+
'Yandex': 'yandex.ru',
|
|
53
|
+
'AOL': 'aol.com',
|
|
54
|
+
'Ask': 'ask.com',
|
|
55
|
+
'Ecosia': 'ecosia.org',
|
|
56
|
+
'Qwant': 'qwant.com',
|
|
57
|
+
'Naver': 'naver.com',
|
|
58
|
+
'Seznam': 'seznam.cz',
|
|
59
|
+
'Sogou': 'sogou.com',
|
|
60
|
+
'Startpage': 'startpage.com',
|
|
61
|
+
'Perplexity': 'perplexity.ai',
|
|
62
|
+
'ChatGPT': 'chatgpt.com',
|
|
63
|
+
'Facebook': 'facebook.com',
|
|
64
|
+
'Instagram': 'instagram.com',
|
|
65
|
+
'Twitter': 'twitter.com',
|
|
66
|
+
'LinkedIn': 'linkedin.com',
|
|
67
|
+
'Pinterest': 'pinterest.com',
|
|
68
|
+
'Reddit': 'reddit.com',
|
|
69
|
+
'YouTube': 'youtube.com',
|
|
70
|
+
'TikTok': 'tiktok.com',
|
|
71
|
+
'WhatsApp': 'web.whatsapp.com',
|
|
72
|
+
'Telegram': 'web.telegram.org',
|
|
73
|
+
'Snapchat': 'snapchat.com',
|
|
74
|
+
'Threads': 'threads.net',
|
|
75
|
+
'Discord': 'discord.com',
|
|
76
|
+
'Quora': 'quora.com',
|
|
77
|
+
'VK': 'vk.com',
|
|
78
|
+
'Weibo': 'weibo.com',
|
|
79
|
+
'GitHub': 'github.com',
|
|
80
|
+
'StackOverflow': 'stackoverflow.com',
|
|
81
|
+
'Hacker News': 'news.ycombinator.com',
|
|
82
|
+
'Gmail': 'mail.google.com',
|
|
83
|
+
'Outlook.com': 'mail.live.com'
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
type SourcesPanelProps = {
|
|
87
|
+
initialData: ListPayload
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export default function SourcesPanel({ initialData }: SourcesPanelProps) {
|
|
91
|
+
const { query, updateQuery } = useQueryContext()
|
|
92
|
+
const site = useSiteContext()
|
|
93
|
+
|
|
94
|
+
const [data, setData] = useState<ListPayload>(initialData)
|
|
95
|
+
const [loading, setLoading] = useState(false)
|
|
96
|
+
const [detailsOpen, setDetailsOpen] = useState(false)
|
|
97
|
+
const [refDetailsOpen, setRefDetailsOpen] = useState(false)
|
|
98
|
+
const [mode, setMode] = useState(() => {
|
|
99
|
+
// Prefer explicit URL mode first (so copied links restore the tab),
|
|
100
|
+
// then infer from filters (utm > channel), else stored choice.
|
|
101
|
+
const urlMode = (query.mode as string | undefined)
|
|
102
|
+
if (urlMode && ALLOWED_MODES.includes(urlMode)) return urlMode
|
|
103
|
+
const filters = query.filters || {}
|
|
104
|
+
const pickUtmMode = () => {
|
|
105
|
+
if ((filters as any).utm_medium) return 'utm-medium'
|
|
106
|
+
if ((filters as any).utm_source) return 'utm-source'
|
|
107
|
+
if ((filters as any).utm_campaign) return 'utm-campaign'
|
|
108
|
+
if ((filters as any).utm_content) return 'utm-content'
|
|
109
|
+
if ((filters as any).utm_term) return 'utm-term'
|
|
110
|
+
return null
|
|
111
|
+
}
|
|
112
|
+
const utmMode = pickUtmMode()
|
|
113
|
+
if (utmMode) return utmMode
|
|
114
|
+
if (filters.channel) return 'channels'
|
|
115
|
+
if (typeof window !== 'undefined') {
|
|
116
|
+
const stored = localStorage.getItem(`${STORAGE_PREFIX}.${site.domain}`)
|
|
117
|
+
if (stored && ALLOWED_MODES.includes(stored)) return stored
|
|
118
|
+
}
|
|
119
|
+
return 'channels'
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
const applyFilter = useCallback(
|
|
123
|
+
(key: string, value: string) => {
|
|
124
|
+
updateQuery((current) => ({
|
|
125
|
+
...current,
|
|
126
|
+
filters: { ...current.filters, [key]: value }
|
|
127
|
+
}))
|
|
128
|
+
},
|
|
129
|
+
[updateQuery]
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
useEffect(() => {
|
|
133
|
+
const controller = new AbortController()
|
|
134
|
+
setLoading(true)
|
|
135
|
+
fetchSources(query, { mode }, controller.signal)
|
|
136
|
+
.then(setData)
|
|
137
|
+
.catch((error) => {
|
|
138
|
+
if (error.name !== 'AbortError') console.error(error)
|
|
139
|
+
})
|
|
140
|
+
.finally(() => setLoading(false))
|
|
141
|
+
|
|
142
|
+
return () => controller.abort()
|
|
143
|
+
}, [mode, query])
|
|
144
|
+
|
|
145
|
+
// Deep-link: open dialogs based on URL on first mount
|
|
146
|
+
const didInitRef = useRef(false)
|
|
147
|
+
useEffect(() => {
|
|
148
|
+
if (didInitRef.current) return
|
|
149
|
+
didInitRef.current = true
|
|
150
|
+
|
|
151
|
+
try {
|
|
152
|
+
const parsed = parseDialogFromPath(window.location.pathname)
|
|
153
|
+
if (parsed.type === 'referrers') {
|
|
154
|
+
const source = parsed.source
|
|
155
|
+
updateQuery((current) => ({
|
|
156
|
+
...current,
|
|
157
|
+
filters: { ...current.filters, source }
|
|
158
|
+
}))
|
|
159
|
+
setMode('all')
|
|
160
|
+
if (/^google$/i.test(source)) {
|
|
161
|
+
// Mirror Plausible: /referrers/Google opens Google Keywords (Search Terms)
|
|
162
|
+
setDetailsOpen(true)
|
|
163
|
+
} else {
|
|
164
|
+
setRefDetailsOpen(true)
|
|
165
|
+
}
|
|
166
|
+
return
|
|
167
|
+
}
|
|
168
|
+
if (parsed.type === 'segment') {
|
|
169
|
+
const nextMode = modeForSegment(parsed.segment)
|
|
170
|
+
if (nextMode) {
|
|
171
|
+
setAndStoreMode(nextMode)
|
|
172
|
+
setDetailsOpen(true)
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
} catch (e) {
|
|
176
|
+
// ignore
|
|
177
|
+
}
|
|
178
|
+
}, [])
|
|
179
|
+
|
|
180
|
+
// Auto-switch mode when URL adds/removes specific UTM filters, to mirror Plausible UX.
|
|
181
|
+
useEffect(() => {
|
|
182
|
+
const filters = query.filters || {}
|
|
183
|
+
const utmToMode: Array<[string, string]> = [
|
|
184
|
+
['utm_medium', 'utm-medium'],
|
|
185
|
+
['utm_source', 'utm-source'],
|
|
186
|
+
['utm_campaign', 'utm-campaign'],
|
|
187
|
+
['utm_content', 'utm-content'],
|
|
188
|
+
['utm_term', 'utm-term']
|
|
189
|
+
]
|
|
190
|
+
const next = utmToMode.find(([k]) => Boolean((filters as any)[k]))?.[1]
|
|
191
|
+
if (next && mode !== next) {
|
|
192
|
+
setAndStoreMode(next)
|
|
193
|
+
} else if (!next && filters.channel && mode !== 'channels') {
|
|
194
|
+
// Do not force-switch to 'channels' if the user drilled into Sources from Channels.
|
|
195
|
+
// Respect explicit 'all' (Sources) when present in the query or current UI state.
|
|
196
|
+
if ((query as any).mode !== 'all') {
|
|
197
|
+
setAndStoreMode('channels')
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}, [query.filters])
|
|
201
|
+
|
|
202
|
+
// Drilldown for a selected source (when mode === 'all')
|
|
203
|
+
const activeSource = query.filters?.source
|
|
204
|
+
const isGoogleActive = useMemo(() => !!(activeSource && /google/i.test(String(activeSource))), [activeSource])
|
|
205
|
+
const isDirectNoneActive = useMemo(() => {
|
|
206
|
+
if (!activeSource) return false
|
|
207
|
+
const s = String(activeSource).trim().toLowerCase()
|
|
208
|
+
return s === 'direct / none' || s === '(none)' || s === 'direct' || s === 'none'
|
|
209
|
+
}, [activeSource])
|
|
210
|
+
// Allow takeover even for Direct / None (matches Plausible behavior for referrers card)
|
|
211
|
+
const takeOverWithReferrers = useMemo(() => mode === 'all' && !!activeSource && !isGoogleActive, [mode, activeSource, isGoogleActive])
|
|
212
|
+
const [refData, setRefData] = useState<ListPayload | null>(null)
|
|
213
|
+
const [refLoading, setRefLoading] = useState(false)
|
|
214
|
+
const [termsData, setTermsData] = useState<ListPayload | null>(null)
|
|
215
|
+
const [termsLoading, setTermsLoading] = useState(false)
|
|
216
|
+
|
|
217
|
+
useEffect(() => {
|
|
218
|
+
if (mode !== 'all' || !activeSource) {
|
|
219
|
+
setRefData(null)
|
|
220
|
+
return
|
|
221
|
+
}
|
|
222
|
+
const controller = new AbortController()
|
|
223
|
+
setRefLoading(true)
|
|
224
|
+
fetchReferrers(query, { source: activeSource }, controller.signal)
|
|
225
|
+
.then(setRefData)
|
|
226
|
+
.catch((error) => { if (error.name !== 'AbortError') console.error(error) })
|
|
227
|
+
.finally(() => setRefLoading(false))
|
|
228
|
+
return () => controller.abort()
|
|
229
|
+
}, [mode, activeSource, query])
|
|
230
|
+
|
|
231
|
+
// Fetch search terms when Google is active
|
|
232
|
+
useEffect(() => {
|
|
233
|
+
if (mode !== 'all' || !isGoogleActive) {
|
|
234
|
+
setTermsData(null)
|
|
235
|
+
return
|
|
236
|
+
}
|
|
237
|
+
const controller = new AbortController()
|
|
238
|
+
setTermsLoading(true)
|
|
239
|
+
fetchSearchTerms(query, {}, controller.signal)
|
|
240
|
+
.then(setTermsData)
|
|
241
|
+
.catch((error) => { if (error.name !== 'AbortError') console.error(error) })
|
|
242
|
+
.finally(() => setTermsLoading(false))
|
|
243
|
+
return () => controller.abort()
|
|
244
|
+
}, [mode, isGoogleActive, query])
|
|
245
|
+
|
|
246
|
+
// Auto-switch behavior: if we are on 'all' and the 'channel' filter is removed, switch back to 'channels'
|
|
247
|
+
const prevQueryRef = useRef(query)
|
|
248
|
+
useEffect(() => {
|
|
249
|
+
const prev = prevQueryRef.current
|
|
250
|
+
const removedChannel = prev.filters && prev.filters.channel && !query.filters.channel
|
|
251
|
+
if (mode === 'all' && removedChannel) {
|
|
252
|
+
setMode('channels')
|
|
253
|
+
if (typeof window !== 'undefined') {
|
|
254
|
+
localStorage.setItem(`${STORAGE_PREFIX}.${site.domain}`, 'channels')
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
prevQueryRef.current = query
|
|
258
|
+
}, [mode, query, site.domain])
|
|
259
|
+
|
|
260
|
+
const highlightMetric = useMemo(
|
|
261
|
+
() => (data.metrics.includes('visitors') ? 'visitors' : data.metrics[0]),
|
|
262
|
+
[data.metrics]
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
// Card title follows Plausible: "Top Channels" on card, but modal uses
|
|
266
|
+
// "Top Acquisition Channels". For other tabs, both are identical.
|
|
267
|
+
const cardTitle = useMemo(() => {
|
|
268
|
+
if (mode === 'channels') return 'Top Channels'
|
|
269
|
+
if (mode === 'all' && isGoogleActive) return 'Search Terms'
|
|
270
|
+
if (takeOverWithReferrers) return 'Top Referrers'
|
|
271
|
+
return TITLE_FOR_MODE[mode] ?? 'Top Sources'
|
|
272
|
+
}, [mode, isGoogleActive, takeOverWithReferrers])
|
|
273
|
+
|
|
274
|
+
const dialogTitle = useMemo(() => {
|
|
275
|
+
if (mode === 'channels') return 'Top Acquisition Channels'
|
|
276
|
+
return TITLE_FOR_MODE[mode] ?? 'Top Sources'
|
|
277
|
+
}, [mode])
|
|
278
|
+
const campaignActive = useMemo(() => CAMPAIGN_OPTIONS.some((option) => option.value === mode), [mode])
|
|
279
|
+
const campaignLabel = useMemo(() => {
|
|
280
|
+
if (!campaignActive) return 'Campaigns'
|
|
281
|
+
const activeOption = CAMPAIGN_OPTIONS.find((option) => option.value === mode)
|
|
282
|
+
return activeOption?.label ?? 'Campaigns'
|
|
283
|
+
}, [campaignActive, mode])
|
|
284
|
+
|
|
285
|
+
const firstColumnLabel = useMemo(() => {
|
|
286
|
+
if (mode === 'channels') return 'Channel'
|
|
287
|
+
if (mode.startsWith('utm-')) {
|
|
288
|
+
const label = CAMPAIGN_OPTIONS.find((opt) => opt.value === mode)?.label || 'Campaign'
|
|
289
|
+
return label.replace(/s$/, '') // Remove trailing 's' for singular
|
|
290
|
+
}
|
|
291
|
+
return 'Source'
|
|
292
|
+
}, [mode])
|
|
293
|
+
|
|
294
|
+
const setAndStoreMode = useCallback(
|
|
295
|
+
(value: string) => {
|
|
296
|
+
setMode(value)
|
|
297
|
+
if (typeof window !== 'undefined') {
|
|
298
|
+
localStorage.setItem(`${STORAGE_PREFIX}.${site.domain}`, value)
|
|
299
|
+
}
|
|
300
|
+
},
|
|
301
|
+
[site.domain]
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
// Limit card view to top 9 by the first metric; Details keeps full list
|
|
305
|
+
const limitedData = useMemo((): ListPayload => {
|
|
306
|
+
const metricKey = data.metrics[0] ?? 'visitors'
|
|
307
|
+
const sorted = [...data.results].sort((a, b) => {
|
|
308
|
+
const av = Number(a[metricKey] ?? 0)
|
|
309
|
+
const bv = Number(b[metricKey] ?? 0)
|
|
310
|
+
if (av === bv) return String(a.name).localeCompare(String(b.name))
|
|
311
|
+
return bv - av
|
|
312
|
+
})
|
|
313
|
+
const sliced = sorted.slice(0, 9)
|
|
314
|
+
return { ...data, metrics: ['visitors'] as ListMetricKey[], results: sliced, meta: { ...data.meta, hasMore: data.results.length > 9 } }
|
|
315
|
+
}, [data])
|
|
316
|
+
|
|
317
|
+
// Treat UTM tabs with mostly "(none)" as no usable data, so we don't display a meaningless list
|
|
318
|
+
const isUtmMode = useMemo(() => mode.startsWith('utm-'), [mode])
|
|
319
|
+
const utmHasUsableData = useMemo(() => {
|
|
320
|
+
if (!isUtmMode) return true
|
|
321
|
+
if (!data || !data.results) return false
|
|
322
|
+
const rows = data.results as Array<Record<string, any>>
|
|
323
|
+
const total = rows.reduce((sum, r) => sum + Number(r.visitors ?? 0), 0)
|
|
324
|
+
const nonNone = rows.filter((r) => {
|
|
325
|
+
const name = String(r.name ?? '').trim()
|
|
326
|
+
return name !== '' && name !== '(none)' && name.toLowerCase() !== '(not set)'
|
|
327
|
+
})
|
|
328
|
+
const nonNoneTotal = nonNone.reduce((sum, r) => sum + Number(r.visitors ?? 0), 0)
|
|
329
|
+
if (nonNone.length === 0) return false
|
|
330
|
+
// Hide when non-tagged dominates (>= 90% is (none))
|
|
331
|
+
return (nonNoneTotal / Math.max(total, 1)) >= 0.10
|
|
332
|
+
}, [isUtmMode, data])
|
|
333
|
+
|
|
334
|
+
return (
|
|
335
|
+
<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="sources-panel">
|
|
336
|
+
<header className="flex flex-wrap items-center justify-between gap-3">
|
|
337
|
+
<h2 className="text-lg/6 font-semibold text-foreground/80">{cardTitle}</h2>
|
|
338
|
+
{/* Hide tabs when referrer or search-terms take over, to match Plausible */}
|
|
339
|
+
{takeOverWithReferrers || (mode === 'all' && isGoogleActive) ? null : (
|
|
340
|
+
<PanelTabs>
|
|
341
|
+
<PanelTab active={mode === 'channels'} onClick={() => setAndStoreMode('channels')}>
|
|
342
|
+
Channels
|
|
343
|
+
</PanelTab>
|
|
344
|
+
<PanelTab active={mode === 'all'} onClick={() => setAndStoreMode('all')}>
|
|
345
|
+
Sources
|
|
346
|
+
</PanelTab>
|
|
347
|
+
<PanelTabDropdown
|
|
348
|
+
active={campaignActive}
|
|
349
|
+
label={campaignLabel}
|
|
350
|
+
options={CAMPAIGN_OPTIONS}
|
|
351
|
+
onSelect={setAndStoreMode}
|
|
352
|
+
/>
|
|
353
|
+
</PanelTabs>
|
|
354
|
+
)}
|
|
355
|
+
</header>
|
|
356
|
+
|
|
357
|
+
{loading ? (
|
|
358
|
+
<div className="flex h-40 items-center justify-center text-sm text-muted-foreground">LoadingâĻ</div>
|
|
359
|
+
) : takeOverWithReferrers ? (
|
|
360
|
+
refLoading ? (
|
|
361
|
+
<div className="flex h-40 items-center justify-center text-sm text-muted-foreground">LoadingâĻ</div>
|
|
362
|
+
) : !refData || refData.results.length === 0 ? (
|
|
363
|
+
<div className="flex h-40 items-center justify-center text-sm text-muted-foreground">No data yet</div>
|
|
364
|
+
) : (
|
|
365
|
+
<>
|
|
366
|
+
<MetricTable
|
|
367
|
+
data={{ ...refData, metrics: ['visitors'] as ListMetricKey[] }}
|
|
368
|
+
firstColumnLabel="Referrer"
|
|
369
|
+
renderLeading={renderSourceIcon}
|
|
370
|
+
displayBars={false}
|
|
371
|
+
barColorTheme="cyan"
|
|
372
|
+
testId="referrers"
|
|
373
|
+
onRowClick={(item) => {
|
|
374
|
+
if (String(item.name) === 'Direct / None') return
|
|
375
|
+
applyFilter('referrer', String(item.name))
|
|
376
|
+
}}
|
|
377
|
+
/>
|
|
378
|
+
<div className="mt-auto flex justify-center pt-3">
|
|
379
|
+
<DetailsButton data-testid="sources-details-btn" onClick={() => {
|
|
380
|
+
setRefDetailsOpen(true)
|
|
381
|
+
try {
|
|
382
|
+
const sp = new URLSearchParams(window.location.search)
|
|
383
|
+
sp.delete('dialog')
|
|
384
|
+
sp.set('mode', mode)
|
|
385
|
+
const qs = sp.toString()
|
|
386
|
+
if (activeSource) {
|
|
387
|
+
const path = buildReferrersPath(activeSource)
|
|
388
|
+
const url = qs ? `${path}?${qs}` : path
|
|
389
|
+
window.history.pushState({}, '', url)
|
|
390
|
+
}
|
|
391
|
+
} catch {}
|
|
392
|
+
}}>Details</DetailsButton>
|
|
393
|
+
</div>
|
|
394
|
+
</>
|
|
395
|
+
)
|
|
396
|
+
) : (mode === 'all' && isGoogleActive) ? (
|
|
397
|
+
termsLoading ? (
|
|
398
|
+
<div className="flex h-40 items-center justify-center text-sm text-muted-foreground">LoadingâĻ</div>
|
|
399
|
+
) : (termsData && termsData.results.length > 0) ? (
|
|
400
|
+
<>
|
|
401
|
+
<MetricTable
|
|
402
|
+
data={{ ...termsData, metrics: ['visitors'] as ListMetricKey[] }}
|
|
403
|
+
firstColumnLabel="Search term"
|
|
404
|
+
displayBars={false}
|
|
405
|
+
barColorTheme="cyan"
|
|
406
|
+
testId="search-terms"
|
|
407
|
+
/>
|
|
408
|
+
<div className="mt-auto flex justify-center pt-3">
|
|
409
|
+
<DetailsButton onClick={() => {
|
|
410
|
+
setDetailsOpen(true)
|
|
411
|
+
try {
|
|
412
|
+
const sp = new URLSearchParams(window.location.search)
|
|
413
|
+
sp.delete('dialog')
|
|
414
|
+
const qs = sp.toString()
|
|
415
|
+
// For Google Search Terms, mirror Plausible route
|
|
416
|
+
window.history.pushState({}, '', buildReferrersPath('Google', qs))
|
|
417
|
+
} catch {}
|
|
418
|
+
}}>Details</DetailsButton>
|
|
419
|
+
</div>
|
|
420
|
+
</>
|
|
421
|
+
) : (
|
|
422
|
+
<div className="flex min-h-40 flex-col items-center justify-center gap-4 rounded-lg border border-border bg-card p-8 text-center">
|
|
423
|
+
<div className="text-foreground/80 text-lg font-semibold">Search Terms</div>
|
|
424
|
+
<div className="max-w-prose text-sm text-muted-foreground">
|
|
425
|
+
No search terms found for this period. This feature is in development.
|
|
426
|
+
</div>
|
|
427
|
+
</div>
|
|
428
|
+
)
|
|
429
|
+
) : data.results.length === 0 || (isUtmMode && !utmHasUsableData) ? (
|
|
430
|
+
<div className="flex h-40 items-center justify-center text-sm text-muted-foreground">No data yet</div>
|
|
431
|
+
) : (
|
|
432
|
+
<>
|
|
433
|
+
<MetricTable
|
|
434
|
+
data={limitedData}
|
|
435
|
+
highlightedMetric={highlightMetric ?? 'visitors'}
|
|
436
|
+
onRowClick={(item) => {
|
|
437
|
+
const name = String(item.name)
|
|
438
|
+
if (mode === 'channels') {
|
|
439
|
+
// Follow Plausible: clicking a channel switches to Sources tab with channel filter; no dialog.
|
|
440
|
+
setAndStoreMode('all')
|
|
441
|
+
updateQuery((current) => ({
|
|
442
|
+
...current,
|
|
443
|
+
mode: 'all',
|
|
444
|
+
filters: { ...current.filters, channel: name }
|
|
445
|
+
}))
|
|
446
|
+
return
|
|
447
|
+
}
|
|
448
|
+
const filterKey = filterKeyForMode(mode)
|
|
449
|
+
applyFilter(filterKey, name)
|
|
450
|
+
}}
|
|
451
|
+
renderLeading={shouldShowIcon(mode) ? renderSourceIcon : undefined}
|
|
452
|
+
displayBars={false}
|
|
453
|
+
firstColumnLabel={firstColumnLabel}
|
|
454
|
+
barColorTheme="cyan"
|
|
455
|
+
testId="sources"
|
|
456
|
+
/>
|
|
457
|
+
{!isUtmMode || utmHasUsableData ? (
|
|
458
|
+
<div className="mt-auto flex justify-center pt-3">
|
|
459
|
+
<DetailsButton
|
|
460
|
+
data-testid="sources-details-btn"
|
|
461
|
+
onClick={() => {
|
|
462
|
+
// If a specific source is active, open Referrer Details instead of Sources
|
|
463
|
+
if (mode === 'all' && activeSource && !isGoogleActive) {
|
|
464
|
+
setRefDetailsOpen(true)
|
|
465
|
+
try {
|
|
466
|
+
const sp = new URLSearchParams(window.location.search)
|
|
467
|
+
sp.delete('dialog')
|
|
468
|
+
const qs = sp.toString()
|
|
469
|
+
if (activeSource) {
|
|
470
|
+
window.history.pushState({}, '', buildReferrersPath(String(activeSource), qs))
|
|
471
|
+
}
|
|
472
|
+
} catch {}
|
|
473
|
+
} else {
|
|
474
|
+
setDetailsOpen(true)
|
|
475
|
+
try {
|
|
476
|
+
const sp = new URLSearchParams(window.location.search)
|
|
477
|
+
sp.delete('dialog')
|
|
478
|
+
sp.delete('mode')
|
|
479
|
+
const qs = sp.toString()
|
|
480
|
+
const stored = (typeof window !== 'undefined') ? localStorage.getItem(`${STORAGE_PREFIX}.${site.domain}`) : null
|
|
481
|
+
const effectiveMode = (stored && ALLOWED_MODES.includes(stored)) ? (stored as any) : (mode as any)
|
|
482
|
+
const seg = dialogSegmentForMode(effectiveMode)
|
|
483
|
+
window.history.pushState({}, '', buildDialogPath(seg, qs))
|
|
484
|
+
} catch {}
|
|
485
|
+
}
|
|
486
|
+
}}
|
|
487
|
+
>
|
|
488
|
+
Details
|
|
489
|
+
</DetailsButton>
|
|
490
|
+
</div>
|
|
491
|
+
) : null}
|
|
492
|
+
</>
|
|
493
|
+
)}
|
|
494
|
+
|
|
495
|
+
{/* Search Terms takes over the card when Google is the active source; no inline drilldown below */}
|
|
496
|
+
|
|
497
|
+
{/* Referrer drilldown card - disabled because main card takes over. */}
|
|
498
|
+
{false && mode === 'all' && activeSource && !isGoogleActive && !isDirectNoneActive ? (
|
|
499
|
+
<section className="flex flex-col gap-3 rounded-xl border border-white/12 bg-[#0f121a] p-4">
|
|
500
|
+
<header className="flex items-center justify-between">
|
|
501
|
+
<h3 className="text-base font-semibold text-foreground/80">Top Referrers</h3>
|
|
502
|
+
</header>
|
|
503
|
+
{refLoading ? (
|
|
504
|
+
<div className="flex h-32 items-center justify-center text-sm text-muted-foreground">LoadingâĻ</div>
|
|
505
|
+
) : !refData || (refData?.results?.length ?? 0) === 0 ? (
|
|
506
|
+
<div className="flex h-32 items-center justify-center text-sm text-muted-foreground">No data yet</div>
|
|
507
|
+
) : (
|
|
508
|
+
<>
|
|
509
|
+
<MetricTable
|
|
510
|
+
data={{ ...(refData as ListPayload), metrics: ['visitors'] as ListMetricKey[] }}
|
|
511
|
+
firstColumnLabel="Referrer"
|
|
512
|
+
renderLeading={renderSourceIcon}
|
|
513
|
+
displayBars={false}
|
|
514
|
+
barColorTheme="indigo"
|
|
515
|
+
onRowClick={(item) => {
|
|
516
|
+
if (String(item.name) === 'Direct / None') return
|
|
517
|
+
applyFilter('referrer', String(item.name))
|
|
518
|
+
}}
|
|
519
|
+
/>
|
|
520
|
+
<div className="mt-auto flex justify-center pt-1">
|
|
521
|
+
<DetailsButton onClick={() => setRefDetailsOpen(true)}>Details</DetailsButton>
|
|
522
|
+
</div>
|
|
523
|
+
</>
|
|
524
|
+
)}
|
|
525
|
+
</section>
|
|
526
|
+
) : null}
|
|
527
|
+
|
|
528
|
+
<RemoteDetailsDialog
|
|
529
|
+
open={detailsOpen}
|
|
530
|
+
onOpenChange={(open) => {
|
|
531
|
+
setDetailsOpen(open)
|
|
532
|
+
try {
|
|
533
|
+
const sp = new URLSearchParams(window.location.search)
|
|
534
|
+
sp.delete('dialog')
|
|
535
|
+
const qs = sp.toString()
|
|
536
|
+
if (open) {
|
|
537
|
+
if (isGoogleActive) {
|
|
538
|
+
// Keep Google keywords route when Search Terms modal is open
|
|
539
|
+
window.history.pushState({}, '', buildReferrersPath('Google', qs))
|
|
540
|
+
} else {
|
|
541
|
+
const seg = dialogSegmentForMode(mode as any)
|
|
542
|
+
window.history.pushState({}, '', buildDialogPath(seg, qs))
|
|
543
|
+
}
|
|
544
|
+
} else {
|
|
545
|
+
window.history.pushState({}, '', baseAnalyticsPath(qs))
|
|
546
|
+
}
|
|
547
|
+
} catch {}
|
|
548
|
+
}}
|
|
549
|
+
title={isGoogleActive ? 'Google Search Terms' : dialogTitle}
|
|
550
|
+
endpoint={isGoogleActive ? analyticsPath('search_terms') : analyticsPath('sources')}
|
|
551
|
+
extras={isGoogleActive ? {} : { mode }}
|
|
552
|
+
firstColumnLabel={isGoogleActive ? 'Search term' : firstColumnLabel}
|
|
553
|
+
defaultSortKey={isGoogleActive ? undefined : 'visitors'}
|
|
554
|
+
onRowClick={(item) => {
|
|
555
|
+
const filterKey = filterKeyForMode(mode)
|
|
556
|
+
applyFilter(filterKey, String(item.name))
|
|
557
|
+
setDetailsOpen(false)
|
|
558
|
+
}}
|
|
559
|
+
renderLeading={isGoogleActive ? undefined : (shouldShowIcon(mode) ? renderSourceIcon : undefined)}
|
|
560
|
+
sortable={!isGoogleActive}
|
|
561
|
+
/>
|
|
562
|
+
|
|
563
|
+
{/* Referrer Details modal */}
|
|
564
|
+
{mode === 'all' && activeSource ? (
|
|
565
|
+
<RemoteDetailsDialog
|
|
566
|
+
open={refDetailsOpen}
|
|
567
|
+
onOpenChange={(open) => {
|
|
568
|
+
setRefDetailsOpen(open)
|
|
569
|
+
try {
|
|
570
|
+
const sp = new URLSearchParams(window.location.search)
|
|
571
|
+
sp.delete('dialog')
|
|
572
|
+
const qs = sp.toString()
|
|
573
|
+
if (open && activeSource) {
|
|
574
|
+
window.history.pushState({}, '', buildReferrersPath(String(activeSource), qs))
|
|
575
|
+
} else if (!open) {
|
|
576
|
+
window.history.pushState({}, '', baseAnalyticsPath(qs))
|
|
577
|
+
}
|
|
578
|
+
} catch (e) {
|
|
579
|
+
// eslint-disable-next-line no-console
|
|
580
|
+
console.warn('Failed to push dialog path', e)
|
|
581
|
+
}
|
|
582
|
+
}}
|
|
583
|
+
title={'Referrer Drilldown'}
|
|
584
|
+
endpoint={analyticsPath('referrers')}
|
|
585
|
+
extras={{ source: activeSource }}
|
|
586
|
+
firstColumnLabel={'Referrer'}
|
|
587
|
+
defaultSortKey={'visitors'}
|
|
588
|
+
onRowClick={(item) => {
|
|
589
|
+
if (String(item.name) === 'Direct / None') return
|
|
590
|
+
applyFilter('referrer', String(item.name))
|
|
591
|
+
setRefDetailsOpen(false)
|
|
592
|
+
}}
|
|
593
|
+
renderLeading={renderSourceIcon}
|
|
594
|
+
getExternalLinkUrl={(item) => {
|
|
595
|
+
const name = String(item.name)
|
|
596
|
+
if (!name || name === 'Direct / None' || name.startsWith('(')) return null
|
|
597
|
+
// If it already looks like a URL with scheme, use as is. Else prefix https://
|
|
598
|
+
return /^(https?:)?\/\//i.test(name) ? (name.startsWith('http') ? name : `https:${name}`) : `https://${name}`
|
|
599
|
+
}}
|
|
600
|
+
/>
|
|
601
|
+
) : null}
|
|
602
|
+
|
|
603
|
+
</section>
|
|
604
|
+
)
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
function shouldShowIcon(mode: string) {
|
|
608
|
+
return mode === 'all' || mode === 'utm-source'
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
function filterKeyForMode(mode: string) {
|
|
612
|
+
switch (mode) {
|
|
613
|
+
case 'channels':
|
|
614
|
+
return 'channel'
|
|
615
|
+
case 'utm-medium':
|
|
616
|
+
return 'utm_medium'
|
|
617
|
+
case 'utm-source':
|
|
618
|
+
return 'utm_source'
|
|
619
|
+
case 'utm-campaign':
|
|
620
|
+
return 'utm_campaign'
|
|
621
|
+
case 'utm-content':
|
|
622
|
+
return 'utm_content'
|
|
623
|
+
case 'utm-term':
|
|
624
|
+
return 'utm_term'
|
|
625
|
+
case 'all':
|
|
626
|
+
default:
|
|
627
|
+
return 'source'
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
function renderSourceIcon(item: ListItem) {
|
|
632
|
+
const name = String(item.name ?? '').trim()
|
|
633
|
+
return <SourceIcon name={name} />
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
function SourceIcon({ name }: { name: string }) {
|
|
637
|
+
const [error, setError] = useState(false)
|
|
638
|
+
const slug = name.toLowerCase()
|
|
639
|
+
|
|
640
|
+
// Traffic category sources (no real domain) - use emojis directly
|
|
641
|
+
const CATEGORY_EMOJIS: Record<string, string> = {
|
|
642
|
+
'Direct / None': 'âŠī¸',
|
|
643
|
+
'Organic Search': 'đ',
|
|
644
|
+
'Organic Social': 'đĨ',
|
|
645
|
+
'Paid Search': 'đ°',
|
|
646
|
+
'Email': 'âī¸',
|
|
647
|
+
'Referral': 'đ'
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
const fallbackEmoji = (): string | null => {
|
|
651
|
+
if (slug.includes('google')) return 'đ'
|
|
652
|
+
if (slug.includes('facebook')) return 'đ'
|
|
653
|
+
if (slug.includes('twitter') || slug.includes('x.com')) return 'đĻ'
|
|
654
|
+
if (slug.includes('github')) return 'đ'
|
|
655
|
+
if (slug.includes('bing')) return 'đ
ąī¸'
|
|
656
|
+
if (slug.includes('brave')) return 'đĻ'
|
|
657
|
+
if (slug.includes('duck')) return 'đĻ'
|
|
658
|
+
if (slug.includes('email')) return 'âī¸'
|
|
659
|
+
if (slug.includes('direct') || slug.includes('none')) return 'âŠī¸'
|
|
660
|
+
if (slug.includes('linkedin')) return 'đŧ'
|
|
661
|
+
if (slug.includes('youtube')) return 'đē'
|
|
662
|
+
if (slug.includes('reddit')) return 'đ¤'
|
|
663
|
+
if (slug.includes('instagram')) return 'đˇ'
|
|
664
|
+
if (slug.includes('search')) return 'đ'
|
|
665
|
+
if (slug.includes('social')) return 'đĨ'
|
|
666
|
+
if (slug.includes('referral') || slug.includes('link')) return 'đ'
|
|
667
|
+
return null
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
if (!name) {
|
|
671
|
+
return fallbackBadge('#')
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
// Check if this is a traffic category (not a real domain)
|
|
675
|
+
if (CATEGORY_EMOJIS[name]) {
|
|
676
|
+
return (
|
|
677
|
+
<span className="flex size-6 items-center justify-center text-lg" aria-hidden>
|
|
678
|
+
{CATEGORY_EMOJIS[name]}
|
|
679
|
+
</span>
|
|
680
|
+
)
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
// If image failed to load, show emoji or badge
|
|
684
|
+
if (error) {
|
|
685
|
+
const emoji = fallbackEmoji()
|
|
686
|
+
if (emoji) {
|
|
687
|
+
return (
|
|
688
|
+
<span className="flex size-6 items-center justify-center text-lg" aria-hidden>
|
|
689
|
+
{emoji}
|
|
690
|
+
</span>
|
|
691
|
+
)
|
|
692
|
+
}
|
|
693
|
+
return fallbackBadge(name)
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
// Plausible proxies favicons through their server for privacy.
|
|
697
|
+
// We use DuckDuckGo directly (same service Plausible uses internally).
|
|
698
|
+
// Derive a safe domain:
|
|
699
|
+
// - Known mappings for categorized sources (e.g. "Google" -> google.com)
|
|
700
|
+
// - If it's a URL, parse and use hostname
|
|
701
|
+
// - If it's a bare domain with a slash, take the hostname part
|
|
702
|
+
// - Otherwise, fall back to emoji/badge for non-domains like "(none)" or "Direct / None"
|
|
703
|
+
let domain: string
|
|
704
|
+
|
|
705
|
+
if (FAVICON_DOMAIN_MAP[name]) {
|
|
706
|
+
// Use mapped domain for categorized sources
|
|
707
|
+
domain = FAVICON_DOMAIN_MAP[name]
|
|
708
|
+
} else {
|
|
709
|
+
// Names that should not attempt favicon fetch
|
|
710
|
+
if (/^\(none\)$/i.test(name) || /direct/.test(slug)) {
|
|
711
|
+
const emoji = fallbackEmoji()
|
|
712
|
+
return (
|
|
713
|
+
<span className="flex size-6 items-center justify-center text-lg" aria-hidden>
|
|
714
|
+
{emoji ?? 'âŠī¸'}
|
|
715
|
+
</span>
|
|
716
|
+
)
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
// Try to parse a full URL
|
|
720
|
+
try {
|
|
721
|
+
let urlStr = name
|
|
722
|
+
if (/^\/\//.test(urlStr)) urlStr = `https:${urlStr}`
|
|
723
|
+
if (!/^https?:/i.test(urlStr) && /\./.test(urlStr)) {
|
|
724
|
+
urlStr = `https://${urlStr}`
|
|
725
|
+
}
|
|
726
|
+
const u = new URL(urlStr)
|
|
727
|
+
domain = u.hostname
|
|
728
|
+
} catch {
|
|
729
|
+
// Fallback: take token before first slash when it looks domain-like
|
|
730
|
+
if (/^[a-z0-9.-]+\.[a-z]{2,}$/i.test(name)) {
|
|
731
|
+
domain = name.split('/')[0]
|
|
732
|
+
} else {
|
|
733
|
+
// Give up on favicon; show emoji/badge instead
|
|
734
|
+
const emoji = fallbackEmoji()
|
|
735
|
+
return emoji ? (
|
|
736
|
+
<span className="flex size-6 items-center justify-center text-lg" aria-hidden>
|
|
737
|
+
{emoji}
|
|
738
|
+
</span>
|
|
739
|
+
) : (
|
|
740
|
+
fallbackBadge(name)
|
|
741
|
+
)
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
const faviconUrl = `https://icons.duckduckgo.com/ip3/${encodeURIComponent(domain)}.ico`
|
|
747
|
+
|
|
748
|
+
return (
|
|
749
|
+
<span className="flex size-6 items-center justify-center" aria-hidden>
|
|
750
|
+
<img
|
|
751
|
+
src={faviconUrl}
|
|
752
|
+
alt=""
|
|
753
|
+
className="h-5 w-5 shrink-0 object-contain"
|
|
754
|
+
onError={() => setError(true)}
|
|
755
|
+
referrerPolicy="no-referrer"
|
|
756
|
+
/>
|
|
757
|
+
</span>
|
|
758
|
+
)
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
function fallbackBadge(value: string) {
|
|
762
|
+
const badge = value.slice(0, 1).toUpperCase() || '#'
|
|
763
|
+
const palette = ['bg-indigo-100 text-indigo-700', 'bg-emerald-100 text-emerald-700', 'bg-sky-100 text-sky-700', 'bg-amber-100 text-amber-700', 'bg-rose-100 text-rose-700']
|
|
764
|
+
const hash = value.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0)
|
|
765
|
+
const classes = palette[hash % palette.length]
|
|
766
|
+
return (
|
|
767
|
+
<span className={`flex size-6 items-center justify-center rounded-full text-[10px] font-semibold ${classes}`} aria-hidden>
|
|
768
|
+
{badge}
|
|
769
|
+
</span>
|
|
770
|
+
)
|
|
771
|
+
}
|