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