@stacksjs/ts-analytics 0.1.6

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 (237) hide show
  1. package/CHANGELOG.md +68 -0
  2. package/LICENSE.md +21 -0
  3. package/README.md +361 -0
  4. package/bin/cli.ts +169 -0
  5. package/dist/Analytics.d.ts +558 -0
  6. package/dist/api.d.ts +109 -0
  7. package/dist/batching.d.ts +93 -0
  8. package/dist/chunk-2mx7fq49.js +4 -0
  9. package/dist/chunk-3z29508k.js +204 -0
  10. package/dist/chunk-deephkz6.js +56 -0
  11. package/dist/chunk-j261vgyp.js +305 -0
  12. package/dist/chunk-xga17tz7.js +207 -0
  13. package/dist/config.d.ts +132 -0
  14. package/dist/dashboard/components/index.d.ts +80 -0
  15. package/dist/dashboard/composables/useAnalytics.d.ts +99 -0
  16. package/dist/dashboard/index.d.ts +110 -0
  17. package/dist/dashboard/types/index.d.ts +144 -0
  18. package/dist/dashboard/utils/index.d.ts +81 -0
  19. package/dist/dynamodb.d.ts +86 -0
  20. package/dist/funnels.d.ts +104 -0
  21. package/dist/geolocation.d.ts +96 -0
  22. package/dist/index.d.ts +371 -0
  23. package/dist/index.js +10211 -0
  24. package/dist/infrastructure/cdk.d.ts +60 -0
  25. package/dist/infrastructure/cloudformation.d.ts +44 -0
  26. package/dist/infrastructure/index.d.ts +45 -0
  27. package/dist/infrastructure/setup.d.ts +133 -0
  28. package/dist/integrations/cloudflare.d.ts +76 -0
  29. package/dist/integrations/hono.d.ts +60 -0
  30. package/dist/integrations/index.d.ts +24 -0
  31. package/dist/integrations/nuxt.d.ts +7 -0
  32. package/dist/integrations/nuxt.js +42 -0
  33. package/dist/integrations/runtime/use-ts-analytics.d.ts +18 -0
  34. package/dist/integrations/runtime/use-ts-analytics.js +17 -0
  35. package/dist/integrations/stx.d.ts +88 -0
  36. package/dist/integrations/stx.js +18 -0
  37. package/dist/lib/crypto-random.d.ts +4 -0
  38. package/dist/lib/salt.d.ts +16 -0
  39. package/dist/local.d.ts +56 -0
  40. package/dist/model-connector.d.ts +145 -0
  41. package/dist/models/AggregatedStats.d.ts +9 -0
  42. package/dist/models/CampaignStats.d.ts +9 -0
  43. package/dist/models/Conversion.d.ts +11 -0
  44. package/dist/models/CustomEvent.d.ts +11 -0
  45. package/dist/models/DeviceStats.d.ts +9 -0
  46. package/dist/models/EventStats.d.ts +9 -0
  47. package/dist/models/GeoStats.d.ts +9 -0
  48. package/dist/models/Goal.d.ts +9 -0
  49. package/dist/models/GoalStats.d.ts +9 -0
  50. package/dist/models/PageStats.d.ts +11 -0
  51. package/dist/models/PageView.d.ts +13 -0
  52. package/dist/models/RealtimeStats.d.ts +9 -0
  53. package/dist/models/ReferrerStats.d.ts +9 -0
  54. package/dist/models/Session.d.ts +11 -0
  55. package/dist/models/Site.d.ts +11 -0
  56. package/dist/models/index.d.ts +28 -0
  57. package/dist/models/types.d.ts +60 -0
  58. package/dist/sqs-buffering.d.ts +243 -0
  59. package/dist/stacks-integration.d.ts +159 -0
  60. package/dist/tracking-script.d.ts +71 -0
  61. package/dist/tracking.d.ts +17 -0
  62. package/dist/tracking.js +26 -0
  63. package/dist/types.d.ts +595 -0
  64. package/dist/utils/geolocation.d.ts +111 -0
  65. package/dist/utils/user-agent.d.ts +17 -0
  66. package/dist/version.d.ts +7 -0
  67. package/package.json +119 -0
  68. package/src/Analytics.ts +3349 -0
  69. package/src/api.ts +1286 -0
  70. package/src/assets/crosswind.css +2220 -0
  71. package/src/batching.ts +452 -0
  72. package/src/components/dashboard/index.ts +18 -0
  73. package/src/config.ts +456 -0
  74. package/src/dashboard/Dashboard.stx +1517 -0
  75. package/src/dashboard/components/AlertCard.stx +177 -0
  76. package/src/dashboard/components/AnalyticsDashboard.stx +354 -0
  77. package/src/dashboard/components/AnimatedNumber.stx +86 -0
  78. package/src/dashboard/components/BarChart.stx +220 -0
  79. package/src/dashboard/components/BrowserBreakdown.stx +98 -0
  80. package/src/dashboard/components/BrowsersTable.stx +125 -0
  81. package/src/dashboard/components/CampaignBreakdown.stx +163 -0
  82. package/src/dashboard/components/CampaignTable.stx +238 -0
  83. package/src/dashboard/components/CountryList.stx +101 -0
  84. package/src/dashboard/components/DataTable.stx +226 -0
  85. package/src/dashboard/components/DateRangePicker.stx +77 -0
  86. package/src/dashboard/components/DeviceBreakdown.stx +94 -0
  87. package/src/dashboard/components/DevicesTable.stx +163 -0
  88. package/src/dashboard/components/DonutChart.stories.ts +55 -0
  89. package/src/dashboard/components/DonutChart.stx +157 -0
  90. package/src/dashboard/components/EmptyState.stx +90 -0
  91. package/src/dashboard/components/EngagementMetrics.stx +183 -0
  92. package/src/dashboard/components/EventsSection.stx +192 -0
  93. package/src/dashboard/components/FilterBar.stx +104 -0
  94. package/src/dashboard/components/FullAnalyticsDashboard.stx +455 -0
  95. package/src/dashboard/components/FunnelChart.stx +142 -0
  96. package/src/dashboard/components/GeoTable.stx +337 -0
  97. package/src/dashboard/components/GoalsPanel.stx +109 -0
  98. package/src/dashboard/components/Header.stx +39 -0
  99. package/src/dashboard/components/HeatmapChart.stx +140 -0
  100. package/src/dashboard/components/LiveActivityFeed.stx +172 -0
  101. package/src/dashboard/components/MetricComparison.stx +106 -0
  102. package/src/dashboard/components/MiniStats.stx +96 -0
  103. package/src/dashboard/components/OSBreakdown.stx +124 -0
  104. package/src/dashboard/components/PageDetailCard.stx +102 -0
  105. package/src/dashboard/components/PagesTable.stx +127 -0
  106. package/src/dashboard/components/ProgressRing.stx +89 -0
  107. package/src/dashboard/components/RealtimeCounter.stories.ts +45 -0
  108. package/src/dashboard/components/RealtimeCounter.stx +46 -0
  109. package/src/dashboard/components/ReferrersTable.stx +180 -0
  110. package/src/dashboard/components/SparklineChart.stx +160 -0
  111. package/src/dashboard/components/StatCard.stories.ts +58 -0
  112. package/src/dashboard/components/StatCard.stx +90 -0
  113. package/src/dashboard/components/SummaryStats.stx +81 -0
  114. package/src/dashboard/components/TabNav.stx +66 -0
  115. package/src/dashboard/components/ThemeSwitcher.stx +124 -0
  116. package/src/dashboard/components/TimeSeriesChart.stx +106 -0
  117. package/src/dashboard/components/TopList.stories.ts +58 -0
  118. package/src/dashboard/components/TopList.stx +74 -0
  119. package/src/dashboard/components/TrendIndicator.stx +84 -0
  120. package/src/dashboard/components/heatmap/ClickHeatmap.stx +264 -0
  121. package/src/dashboard/components/heatmap/ElementClickList.stx +125 -0
  122. package/src/dashboard/components/heatmap/HeatmapControls.stx +125 -0
  123. package/src/dashboard/components/heatmap/HeatmapLegend.stx +69 -0
  124. package/src/dashboard/components/heatmap/PageHeatmap.stx +264 -0
  125. package/src/dashboard/components/heatmap/ScrollHeatmap.stx +127 -0
  126. package/src/dashboard/components/heatmap/index.ts +12 -0
  127. package/src/dashboard/components/index.ts +86 -0
  128. package/src/dashboard/composables/useAnalytics.ts +465 -0
  129. package/src/dashboard/demo/DemoApp.stx +370 -0
  130. package/src/dashboard/demo/index.ts +8 -0
  131. package/src/dashboard/demo/mockData.ts +234 -0
  132. package/src/dashboard/index.ts +117 -0
  133. package/src/dashboard/stx-shim.d.ts +4 -0
  134. package/src/dashboard/types/index.ts +203 -0
  135. package/src/dashboard/utils/index.ts +426 -0
  136. package/src/dynamodb.ts +344 -0
  137. package/src/funnels.ts +534 -0
  138. package/src/geolocation.ts +515 -0
  139. package/src/handlers/alerts.ts +328 -0
  140. package/src/handlers/annotations.ts +128 -0
  141. package/src/handlers/api-keys.ts +240 -0
  142. package/src/handlers/auth.ts +1020 -0
  143. package/src/handlers/authz.ts +137 -0
  144. package/src/handlers/collect.ts +724 -0
  145. package/src/handlers/data.ts +625 -0
  146. package/src/handlers/experiments.ts +138 -0
  147. package/src/handlers/funnels.ts +216 -0
  148. package/src/handlers/goals.ts +218 -0
  149. package/src/handlers/heatmaps.ts +272 -0
  150. package/src/handlers/index.ts +56 -0
  151. package/src/handlers/lib/read-cache.ts +63 -0
  152. package/src/handlers/misc.ts +486 -0
  153. package/src/handlers/oauth.ts +233 -0
  154. package/src/handlers/performance.ts +388 -0
  155. package/src/handlers/sessions.ts +413 -0
  156. package/src/handlers/sharing.ts +198 -0
  157. package/src/handlers/stats.ts +1368 -0
  158. package/src/handlers/team.ts +161 -0
  159. package/src/handlers/uptime.ts +283 -0
  160. package/src/handlers/views.ts +390 -0
  161. package/src/handlers/webhooks.ts +226 -0
  162. package/src/heatmap/index.ts +32 -0
  163. package/src/heatmap/tracking-script.ts +452 -0
  164. package/src/heatmap/types.ts +79 -0
  165. package/src/index.ts +387 -0
  166. package/src/infrastructure/cdk.ts +496 -0
  167. package/src/infrastructure/cloudformation.ts +595 -0
  168. package/src/infrastructure/index.ts +48 -0
  169. package/src/infrastructure/setup.ts +611 -0
  170. package/src/integrations/cloudflare.ts +732 -0
  171. package/src/integrations/hono.ts +589 -0
  172. package/src/integrations/index.ts +27 -0
  173. package/src/integrations/nuxt.ts +78 -0
  174. package/src/integrations/runtime/use-ts-analytics.ts +32 -0
  175. package/src/integrations/stx.ts +138 -0
  176. package/src/jobs/index.ts +127 -0
  177. package/src/lib/crypto-random.ts +41 -0
  178. package/src/lib/ddb-errors.ts +20 -0
  179. package/src/lib/dynamodb.ts +216 -0
  180. package/src/lib/email.ts +38 -0
  181. package/src/lib/ga-import.ts +471 -0
  182. package/src/lib/ga4-api.ts +244 -0
  183. package/src/lib/goals.ts +205 -0
  184. package/src/lib/index.ts +6 -0
  185. package/src/lib/ingest-counters.ts +123 -0
  186. package/src/lib/log.ts +36 -0
  187. package/src/lib/membership.ts +98 -0
  188. package/src/lib/plans.ts +90 -0
  189. package/src/lib/quota.ts +46 -0
  190. package/src/lib/rate-limit.ts +27 -0
  191. package/src/lib/rollups.ts +472 -0
  192. package/src/lib/salt.ts +97 -0
  193. package/src/lib/scheduler.ts +97 -0
  194. package/src/lib/significance.ts +66 -0
  195. package/src/lib/site-retention.ts +56 -0
  196. package/src/local.ts +360 -0
  197. package/src/model-connector.ts +616 -0
  198. package/src/models/AggregatedStats.ts +162 -0
  199. package/src/models/CampaignStats.ts +141 -0
  200. package/src/models/Conversion.ts +135 -0
  201. package/src/models/CustomEvent.ts +123 -0
  202. package/src/models/DeviceStats.ts +105 -0
  203. package/src/models/EventStats.ts +108 -0
  204. package/src/models/GeoStats.ts +112 -0
  205. package/src/models/Goal.ts +105 -0
  206. package/src/models/GoalStats.ts +106 -0
  207. package/src/models/PageStats.ts +152 -0
  208. package/src/models/PageView.ts +277 -0
  209. package/src/models/RealtimeStats.ts +96 -0
  210. package/src/models/ReferrerStats.ts +115 -0
  211. package/src/models/Session.ts +235 -0
  212. package/src/models/Site.ts +115 -0
  213. package/src/models/index.ts +50 -0
  214. package/src/models/orm/index.ts +2043 -0
  215. package/src/models/types.ts +71 -0
  216. package/src/router.ts +521 -0
  217. package/src/sqs-buffering.ts +806 -0
  218. package/src/stacks-integration.ts +527 -0
  219. package/src/tracking-script.ts +944 -0
  220. package/src/tracking.ts +27 -0
  221. package/src/types/analytics.ts +219 -0
  222. package/src/types/api.ts +75 -0
  223. package/src/types/bun-router.d.ts +15 -0
  224. package/src/types/dashboard.ts +62 -0
  225. package/src/types/index.ts +66 -0
  226. package/src/types/stx.d.ts +27 -0
  227. package/src/types/window.d.ts +101 -0
  228. package/src/types.ts +911 -0
  229. package/src/utils/cache.ts +148 -0
  230. package/src/utils/date.ts +118 -0
  231. package/src/utils/filters.ts +71 -0
  232. package/src/utils/geolocation.ts +323 -0
  233. package/src/utils/index.ts +9 -0
  234. package/src/utils/response.ts +180 -0
  235. package/src/utils/timezone-country.ts +242 -0
  236. package/src/utils/user-agent.ts +110 -0
  237. package/src/version.ts +7 -0
@@ -0,0 +1,1517 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>{{ siteName }} - Analytics</title>
7
+ <script>
8
+ // Configuration - auto-detect from current URL or use overrides
9
+ const urlParams = new URLSearchParams(window.location.search)
10
+ const API_ENDPOINT = window.ANALYTICS_API_ENDPOINT || urlParams.get('api') || window.location.origin
11
+ const SITE_ID = urlParams.get('siteId') || window.ANALYTICS_SITE_ID || ''
12
+ const USE_STEALTH = urlParams.get('stealth') !== 'false'
13
+
14
+ // Stealth API path mapper - bypass content blockers
15
+ const STEALTH_MAP = {
16
+ sites: 'projects', stats: 'summary', realtime: 'pulse', pages: 'content',
17
+ referrers: 'sources', devices: 'clients', browsers: 'agents', countries: 'geo',
18
+ timeseries: 'series', events: 'actions', campaigns: 'promo', comparison: 'diff',
19
+ sessions: 'visits', flow: 'journey', live: 'now', heatmap: 'touch', errors: 'issues',
20
+ vitals: 'metrics', goals: 'targets', funnels: 'pipelines', annotations: 'notes'
21
+ }
22
+ function apiPath(path) {
23
+ if (!USE_STEALTH) return path
24
+ let result = path.replace('/api/sites/', '/api/p/').replace('/api/sites', '/api/projects')
25
+ Object.keys(STEALTH_MAP).sort((a, b) => b.length - a.length).forEach(k => {
26
+ result = result.replace(new RegExp(`/${k}(?=/|\\?|$)`, 'g'), `/${STEALTH_MAP[k]}`)
27
+ })
28
+ return result
29
+ }
30
+ window.apiPath = apiPath
31
+
32
+ // Sites list
33
+ let availableSites = []
34
+ let sitesLoading = true
35
+ let sitesError = null
36
+
37
+ // Dashboard state
38
+ let siteName = "Analytics Dashboard"
39
+ let siteId = SITE_ID
40
+ let dateRange = "Last 30 days"
41
+ let activeView = 'dashboard' // 'dashboard' or 'heatmap'
42
+
43
+ // Heatmap state
44
+ let heatmapPages = []
45
+ let selectedHeatmapPage = ''
46
+ let heatmapViewMode = 'click' // 'click' or 'scroll'
47
+ let heatmapClicks = []
48
+ let heatmapTopElements = []
49
+ let heatmapTotalClicks = 0
50
+ let heatmapScrollData = []
51
+ let heatmapAvgScrollDepth = 0
52
+ let heatmapTotalSessions = 0
53
+ let heatmapLoading = false
54
+
55
+ // Summary stats
56
+ let stats = {
57
+ realtime: 0,
58
+ people: 0,
59
+ views: 0,
60
+ avgTime: "00:00",
61
+ bounceRate: 0,
62
+ events: 0
63
+ }
64
+
65
+ // Table data
66
+ let pages = []
67
+ let referrers = []
68
+ let deviceTypes = []
69
+ let operatingSystems = []
70
+ let browsers = []
71
+ let countries = []
72
+ let campaigns = []
73
+ let events = []
74
+ let timeSeriesData = []
75
+
76
+ // Fetch available sites
77
+ async function fetchAvailableSites() {
78
+ sitesLoading = true
79
+ sitesError = null
80
+ try {
81
+ const res = await fetch(apiPath(`${API_ENDPOINT}/api/sites`))
82
+ if (!res.ok) throw new Error('Failed to fetch sites')
83
+ const data = await res.json()
84
+ availableSites = data.sites || []
85
+ sitesLoading = false
86
+ renderSiteSelector()
87
+ } catch (err) {
88
+ sitesError = err.message || 'Failed to load sites'
89
+ sitesLoading = false
90
+ renderSiteSelector()
91
+ }
92
+ }
93
+
94
+ function renderSiteSelector() {
95
+ const container = document.getElementById('site-selector-container')
96
+ if (!container) return
97
+
98
+ if (sitesLoading) {
99
+ container.innerHTML = `
100
+ <div class="site-selector-loading">
101
+ <div class="spinner"></div>
102
+ <p>Loading sites...</p>
103
+ </div>
104
+ `
105
+ return
106
+ }
107
+
108
+ if (sitesError) {
109
+ container.innerHTML = `
110
+ <div class="site-selector-error">
111
+ <svg width="48" height="48" fill="none" stroke="currentColor" viewBox="0 0 24 24">
112
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
113
+ </svg>
114
+ <p>${sitesError}</p>
115
+ <button onclick="fetchAvailableSites()">Try Again</button>
116
+ </div>
117
+ `
118
+ return
119
+ }
120
+
121
+ if (availableSites.length === 0) {
122
+ container.innerHTML = `
123
+ <div class="site-selector-empty">
124
+ <svg width="48" height="48" fill="none" stroke="currentColor" viewBox="0 0 24 24">
125
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9"/>
126
+ </svg>
127
+ <h2>No Sites Found</h2>
128
+ <p>Create a site to start tracking analytics.</p>
129
+ </div>
130
+ `
131
+ return
132
+ }
133
+
134
+ container.innerHTML = `
135
+ <div class="site-selector-grid">
136
+ ${availableSites.map(site => `
137
+ <button class="site-card" onclick="selectSite('${site.id}', '${site.name.replace(/'/g, "\\'")}')">
138
+ <div class="site-card-icon">
139
+ <svg width="24" height="24" fill="none" stroke="currentColor" viewBox="0 0 24 24">
140
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9"/>
141
+ </svg>
142
+ </div>
143
+ <div class="site-card-info">
144
+ <span class="site-card-name">${site.name}</span>
145
+ <span class="site-card-domain">${site.domains?.[0] || site.id}</span>
146
+ </div>
147
+ <svg class="site-card-arrow" width="20" height="20" fill="none" stroke="currentColor" viewBox="0 0 24 24">
148
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
149
+ </svg>
150
+ </button>
151
+ `).join('')}
152
+ </div>
153
+ `
154
+ }
155
+
156
+ function selectSite(id, name) {
157
+ siteId = id
158
+ siteName = name
159
+ document.getElementById('site-selector-screen').style.display = 'none'
160
+ document.getElementById('dashboard-content').style.display = 'block'
161
+ // Update URL without reload
162
+ const url = new URL(window.location.href)
163
+ url.searchParams.set('siteId', id)
164
+ window.history.pushState({}, '', url)
165
+ // Update site name in header
166
+ document.querySelector('.site-selector select').innerHTML = `<option>${name}</option>`
167
+ // Fetch dashboard data
168
+ fetchDashboardData()
169
+ }
170
+
171
+ // Fetch data from API
172
+ async function fetchDashboardData() {
173
+ if (!siteId) {
174
+ console.error('No siteId configured. Add ?siteId=YOUR_SITE_ID to the URL.')
175
+ return
176
+ }
177
+
178
+ const baseUrl = `${API_ENDPOINT}/api/sites/${siteId}`
179
+ const now = new Date()
180
+ const thirtyDaysAgo = new Date(now)
181
+ thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30)
182
+ const params = `?startDate=${thirtyDaysAgo.toISOString()}&endDate=${now.toISOString()}`
183
+
184
+ try {
185
+ // Fetch all data in parallel - using stealth API paths
186
+ const [statsRes, realtimeRes, pagesRes, referrersRes, devicesRes, browsersRes, countriesRes, timeseriesRes, eventsRes, campaignsRes] = await Promise.all([
187
+ fetch(apiPath(`${baseUrl}/stats${params}`)).then(r => r.json()).catch(() => ({})),
188
+ fetch(apiPath(`${baseUrl}/realtime`)).then(r => r.json()).catch(() => ({ currentVisitors: 0 })),
189
+ fetch(apiPath(`${baseUrl}/pages${params}`)).then(r => r.json()).catch(() => ({ pages: [] })),
190
+ fetch(apiPath(`${baseUrl}/referrers${params}`)).then(r => r.json()).catch(() => ({ referrers: [] })),
191
+ fetch(apiPath(`${baseUrl}/devices${params}`)).then(r => r.json()).catch(() => ({ deviceTypes: [], operatingSystems: [] })),
192
+ fetch(apiPath(`${baseUrl}/browsers${params}`)).then(r => r.json()).catch(() => ({ browsers: [] })),
193
+ fetch(apiPath(`${baseUrl}/countries${params}`)).then(r => r.json()).catch(() => ({ countries: [] })),
194
+ fetch(apiPath(`${baseUrl}/timeseries${params}`)).then(r => r.json()).catch(() => ({ timeSeries: [] })),
195
+ fetch(apiPath(`${baseUrl}/events${params}`)).then(r => r.json()).catch(() => ({ events: [] })),
196
+ fetch(apiPath(`${baseUrl}/campaigns${params}`)).then(r => r.json()).catch(() => ({ campaigns: [] })),
197
+ ])
198
+
199
+ // Update stats
200
+ stats = {
201
+ realtime: realtimeRes.currentVisitors || 0,
202
+ people: statsRes.people || 0,
203
+ views: statsRes.views || 0,
204
+ avgTime: statsRes.avgTime || "00:00",
205
+ bounceRate: statsRes.bounceRate || 0,
206
+ events: statsRes.events || 0
207
+ }
208
+
209
+ // Update tables
210
+ pages = pagesRes.pages || []
211
+ referrers = referrersRes.referrers || []
212
+ deviceTypes = devicesRes.deviceTypes || []
213
+ operatingSystems = devicesRes.operatingSystems || []
214
+ browsers = browsersRes.browsers || []
215
+ countries = countriesRes.countries || []
216
+ campaigns = campaignsRes.campaigns || []
217
+ events = eventsRes.events || []
218
+ timeSeriesData = timeseriesRes.timeSeries || []
219
+
220
+ // Re-render the dashboard
221
+ renderDashboard()
222
+ } catch (error) {
223
+ console.error('Failed to fetch dashboard data:', error)
224
+ }
225
+ }
226
+
227
+ function formatNumber(num) {
228
+ if (num >= 1000000) return (num / 1000000).toFixed(1) + 'M'
229
+ if (num >= 1000) return (num / 1000).toFixed(1) + 'k'
230
+ return String(num)
231
+ }
232
+
233
+ // Heatmap functions
234
+ async function fetchHeatmapPages() {
235
+ if (!siteId) return
236
+ try {
237
+ const res = await fetch(apiPath(`${API_ENDPOINT}/api/sites/${siteId}/heatmap/pages`))
238
+ const data = await res.json()
239
+ heatmapPages = data.pages || []
240
+ renderHeatmapPageSelector()
241
+ } catch (err) {
242
+ console.error('Failed to fetch heatmap pages:', err)
243
+ }
244
+ }
245
+
246
+ async function fetchHeatmapData() {
247
+ if (!siteId || !selectedHeatmapPage) return
248
+ heatmapLoading = true
249
+ renderHeatmapLoading()
250
+
251
+ const now = new Date()
252
+ const thirtyDaysAgo = new Date(now)
253
+ thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30)
254
+ const params = `path=${encodeURIComponent(selectedHeatmapPage)}&startDate=${thirtyDaysAgo.toISOString()}&endDate=${now.toISOString()}`
255
+
256
+ try {
257
+ if (heatmapViewMode === 'click') {
258
+ const res = await fetch(apiPath(`${API_ENDPOINT}/api/sites/${siteId}/heatmap/clicks?${params}`))
259
+ const data = await res.json()
260
+ heatmapClicks = data.clicks || []
261
+ heatmapTopElements = data.topElements || []
262
+ heatmapTotalClicks = data.totalClicks || 0
263
+ } else {
264
+ const res = await fetch(apiPath(`${API_ENDPOINT}/api/sites/${siteId}/heatmap/scroll?${params}`))
265
+ const data = await res.json()
266
+ heatmapScrollData = data.scrollDepths || []
267
+ heatmapAvgScrollDepth = data.avgMaxDepth || 0
268
+ heatmapTotalSessions = data.totalSessions || 0
269
+ }
270
+ renderHeatmap()
271
+ } catch (err) {
272
+ console.error('Failed to fetch heatmap data:', err)
273
+ } finally {
274
+ heatmapLoading = false
275
+ }
276
+ }
277
+
278
+ function renderHeatmapPageSelector() {
279
+ const selector = document.getElementById('heatmap-page-select')
280
+ if (!selector) return
281
+ selector.innerHTML = '<option value="">Select a page...</option>' +
282
+ heatmapPages.map(p => `<option value="${p.path}">${p.path} (${p.clickCount || 0} clicks)</option>`).join('')
283
+ }
284
+
285
+ function renderHeatmapLoading() {
286
+ const container = document.getElementById('heatmap-viz')
287
+ if (!container) return
288
+ container.innerHTML = `
289
+ <div class="heatmap-loading">
290
+ <div class="spinner"></div>
291
+ <span>Loading heatmap data...</span>
292
+ </div>
293
+ `
294
+ }
295
+
296
+ function renderHeatmap() {
297
+ if (heatmapViewMode === 'click') {
298
+ renderClickHeatmap()
299
+ } else {
300
+ renderScrollHeatmap()
301
+ }
302
+ renderHeatmapSidebar()
303
+ }
304
+
305
+ function renderClickHeatmap() {
306
+ const container = document.getElementById('heatmap-viz')
307
+ if (!container) return
308
+
309
+ if (heatmapClicks.length === 0) {
310
+ container.innerHTML = `
311
+ <div class="heatmap-empty">
312
+ <svg width="48" height="48" fill="none" stroke="currentColor" viewBox="0 0 24 24">
313
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 15l-2 5L9 9l11 4-5 2zm0 0l5 5M7.188 2.239l.777 2.897M5.136 7.965l-2.898-.777M13.95 4.05l-2.122 2.122m-5.657 5.656l-2.12 2.122"/>
314
+ </svg>
315
+ <p>No click data for this page yet</p>
316
+ </div>
317
+ `
318
+ return
319
+ }
320
+
321
+ // Create canvas-based heatmap
322
+ const width = container.offsetWidth
323
+ const height = 500
324
+ const maxCount = Math.max(...heatmapClicks.map(c => c.count), 1)
325
+
326
+ container.innerHTML = `
327
+ <div class="heatmap-canvas-container" style="position:relative;width:100%;height:${height}px;background:#1a1f2e;border-radius:8px;overflow:hidden;">
328
+ <canvas id="heatmap-canvas" width="${width}" height="${height}"></canvas>
329
+ <div class="heatmap-stats">
330
+ <span>${heatmapTotalClicks.toLocaleString()} total clicks</span>
331
+ </div>
332
+ </div>
333
+ `
334
+
335
+ const canvas = document.getElementById('heatmap-canvas')
336
+ const ctx = canvas.getContext('2d')
337
+
338
+ // Draw click points as heat
339
+ for (const click of heatmapClicks) {
340
+ const intensity = click.count / maxCount
341
+ const x = (click.x / 100) * width
342
+ const y = (click.y / 100) * height
343
+ drawHeatPoint(ctx, x, y, intensity, 30)
344
+ }
345
+
346
+ // Colorize
347
+ colorizeHeatmap(ctx, width, height)
348
+ }
349
+
350
+ function drawHeatPoint(ctx, x, y, intensity, radius) {
351
+ const gradient = ctx.createRadialGradient(x, y, 0, x, y, radius)
352
+ gradient.addColorStop(0, `rgba(255, 255, 255, ${intensity * 0.6})`)
353
+ gradient.addColorStop(1, 'rgba(255, 255, 255, 0)')
354
+ ctx.fillStyle = gradient
355
+ ctx.beginPath()
356
+ ctx.arc(x, y, radius, 0, Math.PI * 2)
357
+ ctx.fill()
358
+ }
359
+
360
+ function colorizeHeatmap(ctx, width, height) {
361
+ const imageData = ctx.getImageData(0, 0, width, height)
362
+ const data = imageData.data
363
+
364
+ for (let i = 0; i < data.length; i += 4) {
365
+ const alpha = data[i + 3]
366
+ if (alpha > 0) {
367
+ const ratio = alpha / 255
368
+ // Blue -> Green -> Yellow -> Red gradient
369
+ if (ratio < 0.25) {
370
+ data[i] = 0
371
+ data[i + 1] = Math.floor(ratio * 4 * 255)
372
+ data[i + 2] = 255
373
+ } else if (ratio < 0.5) {
374
+ data[i] = 0
375
+ data[i + 1] = 255
376
+ data[i + 2] = Math.floor((1 - (ratio - 0.25) * 4) * 255)
377
+ } else if (ratio < 0.75) {
378
+ data[i] = Math.floor((ratio - 0.5) * 4 * 255)
379
+ data[i + 1] = 255
380
+ data[i + 2] = 0
381
+ } else {
382
+ data[i] = 255
383
+ data[i + 1] = Math.floor((1 - (ratio - 0.75) * 4) * 255)
384
+ data[i + 2] = 0
385
+ }
386
+ data[i + 3] = Math.min(255, alpha * 2)
387
+ }
388
+ }
389
+
390
+ ctx.putImageData(imageData, 0, 0)
391
+ }
392
+
393
+ function renderScrollHeatmap() {
394
+ const container = document.getElementById('heatmap-viz')
395
+ if (!container) return
396
+
397
+ if (heatmapScrollData.length === 0) {
398
+ container.innerHTML = `
399
+ <div class="heatmap-empty">
400
+ <svg width="48" height="48" fill="none" stroke="currentColor" viewBox="0 0 24 24">
401
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 14l-7 7m0 0l-7-7m7 7V3"/>
402
+ </svg>
403
+ <p>No scroll data for this page yet</p>
404
+ </div>
405
+ `
406
+ return
407
+ }
408
+
409
+ const getColor = (pct) => {
410
+ if (pct >= 80) return '#10b981'
411
+ if (pct >= 60) return '#34d399'
412
+ if (pct >= 40) return '#fbbf24'
413
+ if (pct >= 20) return '#f97316'
414
+ return '#ef4444'
415
+ }
416
+
417
+ // Create depth buckets 0-100 in 10% increments
418
+ const buckets = []
419
+ for (let d = 0; d <= 90; d += 10) {
420
+ const dataPoint = heatmapScrollData.find(s => s.depth === d)
421
+ buckets.push({
422
+ depth: d,
423
+ reachPercentage: dataPoint?.reachPercentage ?? (d === 0 ? 100 : 0),
424
+ avgTimeMs: dataPoint?.avgTimeMs ?? 0
425
+ })
426
+ }
427
+
428
+ container.innerHTML = `
429
+ <div class="scroll-heatmap">
430
+ <div class="scroll-stats">
431
+ <div class="scroll-stat">
432
+ <span class="scroll-stat-value">${Math.round(heatmapAvgScrollDepth)}%</span>
433
+ <span class="scroll-stat-label">Avg. Scroll Depth</span>
434
+ </div>
435
+ <div class="scroll-stat">
436
+ <span class="scroll-stat-value">${heatmapTotalSessions.toLocaleString()}</span>
437
+ <span class="scroll-stat-label">Total Sessions</span>
438
+ </div>
439
+ </div>
440
+ <div class="scroll-bars">
441
+ ${buckets.map(b => `
442
+ <div class="scroll-bar-row">
443
+ <span class="scroll-depth-label">${b.depth}%</span>
444
+ <div class="scroll-bar-track">
445
+ <div class="scroll-bar-fill" style="width:${b.reachPercentage}%;background:${getColor(b.reachPercentage)}"></div>
446
+ </div>
447
+ <span class="scroll-reach-label">${b.reachPercentage}%</span>
448
+ </div>
449
+ `).join('')}
450
+ </div>
451
+ <div class="scroll-legend">
452
+ <span><span class="legend-dot" style="background:#10b981"></span>80%+ reach</span>
453
+ <span><span class="legend-dot" style="background:#fbbf24"></span>40-60% reach</span>
454
+ <span><span class="legend-dot" style="background:#ef4444"></span>&lt;20% reach</span>
455
+ </div>
456
+ </div>
457
+ `
458
+ }
459
+
460
+ function renderHeatmapSidebar() {
461
+ const sidebar = document.getElementById('heatmap-sidebar')
462
+ if (!sidebar) return
463
+
464
+ if (heatmapViewMode === 'click' && heatmapTopElements.length > 0) {
465
+ const maxClicks = heatmapTopElements[0]?.clicks || 1
466
+ sidebar.innerHTML = `
467
+ <div class="heatmap-panel">
468
+ <h3>Top Clicked Elements</h3>
469
+ <div class="element-list">
470
+ ${heatmapTopElements.slice(0, 15).map(el => `
471
+ <div class="element-item">
472
+ <div class="element-info">
473
+ <span class="element-tag">&lt;${el.tag}&gt;</span>
474
+ <span class="element-selector" title="${el.selector}">${el.selector}</span>
475
+ </div>
476
+ <div class="element-stats">
477
+ <div class="element-bar-track">
478
+ <div class="element-bar-fill" style="width:${(el.clicks / maxClicks) * 100}%"></div>
479
+ </div>
480
+ <span class="element-clicks">${el.clicks}</span>
481
+ </div>
482
+ </div>
483
+ `).join('')}
484
+ </div>
485
+ </div>
486
+ `
487
+ } else if (heatmapViewMode === 'scroll') {
488
+ sidebar.innerHTML = `
489
+ <div class="heatmap-panel">
490
+ <h3>Scroll Insights</h3>
491
+ <div class="insight-list">
492
+ <div class="insight-item">
493
+ <span class="insight-label">Average scroll depth</span>
494
+ <span class="insight-value">${Math.round(heatmapAvgScrollDepth)}%</span>
495
+ </div>
496
+ <div class="insight-item">
497
+ <span class="insight-label">Sessions analyzed</span>
498
+ <span class="insight-value">${heatmapTotalSessions.toLocaleString()}</span>
499
+ </div>
500
+ <div class="insight-item">
501
+ <span class="insight-label">Reached 50%</span>
502
+ <span class="insight-value">${heatmapScrollData.find(s => s.depth === 50)?.reachPercentage || 0}%</span>
503
+ </div>
504
+ <div class="insight-item">
505
+ <span class="insight-label">Reached bottom</span>
506
+ <span class="insight-value">${heatmapScrollData.find(s => s.depth === 90)?.reachPercentage || 0}%</span>
507
+ </div>
508
+ </div>
509
+ </div>
510
+ `
511
+ } else {
512
+ sidebar.innerHTML = `
513
+ <div class="heatmap-panel">
514
+ <h3>No Data</h3>
515
+ <p class="text-muted">Select a page to view heatmap data</p>
516
+ </div>
517
+ `
518
+ }
519
+ }
520
+
521
+ function switchView(view) {
522
+ activeView = view
523
+ document.getElementById('dashboard-view').style.display = view === 'dashboard' ? 'block' : 'none'
524
+ document.getElementById('heatmap-view').style.display = view === 'heatmap' ? 'block' : 'none'
525
+ document.querySelectorAll('.view-tab').forEach(t => t.classList.remove('active'))
526
+ document.querySelector(`.view-tab[data-view="${view}"]`)?.classList.add('active')
527
+
528
+ if (view === 'heatmap' && heatmapPages.length === 0) {
529
+ fetchHeatmapPages()
530
+ }
531
+ }
532
+
533
+ function switchHeatmapMode(mode) {
534
+ heatmapViewMode = mode
535
+ document.querySelectorAll('.heatmap-mode-btn').forEach(b => b.classList.remove('active'))
536
+ document.querySelector(`.heatmap-mode-btn[data-mode="${mode}"]`)?.classList.add('active')
537
+ if (selectedHeatmapPage) {
538
+ fetchHeatmapData()
539
+ }
540
+ }
541
+
542
+ function selectHeatmapPage(path) {
543
+ selectedHeatmapPage = path
544
+ if (path) {
545
+ fetchHeatmapData()
546
+ }
547
+ }
548
+
549
+ function renderDashboard() {
550
+ // Update stats
551
+ document.getElementById('stat-realtime').textContent = formatNumber(stats.realtime)
552
+ document.getElementById('stat-people').textContent = formatNumber(stats.people)
553
+ document.getElementById('stat-views').textContent = formatNumber(stats.views)
554
+ document.getElementById('stat-avgtime').textContent = stats.avgTime
555
+ document.getElementById('stat-bounce').textContent = stats.bounceRate + '%'
556
+ document.getElementById('stat-events').textContent = formatNumber(stats.events)
557
+ document.getElementById('realtime-count').textContent = stats.realtime + ' current visitors'
558
+
559
+ // Update pages table
560
+ const pagesBody = document.getElementById('pages-body')
561
+ pagesBody.innerHTML = pages.length ? pages.map(p => `
562
+ <tr>
563
+ <td class="name" title="${p.path}">${p.path}</td>
564
+ <td class="value">${formatNumber(p.entries || 0)}</td>
565
+ <td class="value">${formatNumber(p.visitors || 0)}</td>
566
+ <td class="value">${formatNumber(p.views || 0)}</td>
567
+ </tr>
568
+ `).join('') : '<tr><td colspan="4" style="text-align:center;color:var(--text-muted);padding:1rem">No data</td></tr>'
569
+
570
+ // Update referrers table
571
+ const referrersBody = document.getElementById('referrers-body')
572
+ referrersBody.innerHTML = referrers.length ? referrers.map(r => `
573
+ <tr>
574
+ <td class="name" title="${r.source}">${r.source}</td>
575
+ <td class="value">${formatNumber(r.visitors || 0)}</td>
576
+ <td class="value">${formatNumber(r.views || 0)}</td>
577
+ </tr>
578
+ `).join('') : '<tr><td colspan="3" style="text-align:center;color:var(--text-muted);padding:1rem">No data</td></tr>'
579
+
580
+ // Update devices table
581
+ const devicesBody = document.getElementById('devices-body')
582
+ devicesBody.innerHTML = deviceTypes.length ? deviceTypes.map(d => `
583
+ <tr>
584
+ <td class="name">${d.type}</td>
585
+ <td class="value">${formatNumber(d.visitors || 0)}</td>
586
+ </tr>
587
+ `).join('') : '<tr><td colspan="2" style="text-align:center;color:var(--text-muted);padding:1rem">No data</td></tr>'
588
+
589
+ // Update browsers table
590
+ const browsersBody = document.getElementById('browsers-body')
591
+ browsersBody.innerHTML = browsers.length ? browsers.map(b => `
592
+ <tr>
593
+ <td class="name">${b.name}</td>
594
+ <td class="value">${formatNumber(b.visitors || 0)}</td>
595
+ </tr>
596
+ `).join('') : '<tr><td colspan="2" style="text-align:center;color:var(--text-muted);padding:1rem">No data</td></tr>'
597
+
598
+ // Update countries table
599
+ const countriesBody = document.getElementById('countries-body')
600
+ countriesBody.innerHTML = countries.length ? countries.map(c => `
601
+ <tr>
602
+ <td class="name">${c.name}</td>
603
+ <td class="value">${formatNumber(c.visitors || 0)}</td>
604
+ </tr>
605
+ `).join('') : '<tr><td colspan="2" style="text-align:center;color:var(--text-muted);padding:1rem">No data</td></tr>'
606
+
607
+ // Update campaigns table
608
+ const campaignsBody = document.getElementById('campaigns-body')
609
+ campaignsBody.innerHTML = campaigns.length ? campaigns.map(c => `
610
+ <tr>
611
+ <td class="name" title="${c.name}">${c.name}</td>
612
+ <td class="value">${formatNumber(c.visitors || 0)}</td>
613
+ <td class="value">${formatNumber(c.views || 0)}</td>
614
+ </tr>
615
+ `).join('') : '<tr><td colspan="3" style="text-align:center;color:var(--text-muted);padding:1rem">No data</td></tr>'
616
+
617
+ // Update events section
618
+ const eventsContainer = document.getElementById('events-container')
619
+ eventsContainer.innerHTML = events.length ? `
620
+ <table class="data-table">
621
+ <thead>
622
+ <tr>
623
+ <th>Event Name</th>
624
+ <th style="text-align:right">Count</th>
625
+ <th style="text-align:right">Unique</th>
626
+ <th style="text-align:right">Value</th>
627
+ </tr>
628
+ </thead>
629
+ <tbody>
630
+ ${events.map(e => `
631
+ <tr>
632
+ <td class="name">${e.name}</td>
633
+ <td class="value">${formatNumber(e.count || 0)}</td>
634
+ <td class="value">${formatNumber(e.unique || 0)}</td>
635
+ <td class="value">${e.totalValue ? formatNumber(e.totalValue) : '-'}</td>
636
+ </tr>
637
+ `).join('')}
638
+ </tbody>
639
+ </table>
640
+ ` : `
641
+ <div class="empty-state">
642
+ <p>You haven't created any events yet.</p>
643
+ <a href="#">Learn how to use events</a>
644
+ </div>
645
+ `
646
+
647
+ // Render time series chart if data available
648
+ renderChart()
649
+ }
650
+
651
+ function renderChart() {
652
+ const canvas = document.getElementById('timeSeriesChart')
653
+ const tooltip = document.getElementById('chartTooltip')
654
+ if (!canvas || !timeSeriesData.length) return
655
+
656
+ const ctx = canvas.getContext('2d')
657
+ const rect = canvas.parentElement.getBoundingClientRect()
658
+ canvas.width = rect.width - 48
659
+ canvas.height = 220
660
+
661
+ const padding = { top: 20, right: 20, bottom: 50, left: 50 }
662
+ const width = canvas.width - padding.left - padding.right
663
+ const height = canvas.height - padding.top - padding.bottom
664
+
665
+ const maxViews = Math.max(...timeSeriesData.map(d => d.views), 1)
666
+ const xScale = width / (timeSeriesData.length - 1 || 1)
667
+ const yScale = height / maxViews
668
+
669
+ // Store point positions for hover detection
670
+ const points = timeSeriesData.map((d, i) => ({
671
+ x: padding.left + i * xScale,
672
+ y: padding.top + height - d.views * yScale,
673
+ data: d
674
+ }))
675
+
676
+ function draw(hoverIndex = -1) {
677
+ ctx.clearRect(0, 0, canvas.width, canvas.height)
678
+
679
+ // Draw grid lines
680
+ ctx.strokeStyle = '#374151'
681
+ ctx.lineWidth = 1
682
+ for (let i = 0; i <= 4; i++) {
683
+ const y = padding.top + (height / 4) * i
684
+ ctx.beginPath()
685
+ ctx.moveTo(padding.left, y)
686
+ ctx.lineTo(padding.left + width, y)
687
+ ctx.stroke()
688
+ }
689
+
690
+ // Draw area fill
691
+ ctx.beginPath()
692
+ ctx.fillStyle = 'rgba(129, 140, 248, 0.1)'
693
+ points.forEach((p, i) => {
694
+ if (i === 0) {
695
+ ctx.moveTo(p.x, padding.top + height)
696
+ ctx.lineTo(p.x, p.y)
697
+ } else {
698
+ ctx.lineTo(p.x, p.y)
699
+ }
700
+ })
701
+ ctx.lineTo(points[points.length - 1].x, padding.top + height)
702
+ ctx.closePath()
703
+ ctx.fill()
704
+
705
+ // Draw line
706
+ ctx.beginPath()
707
+ ctx.strokeStyle = '#818cf8'
708
+ ctx.lineWidth = 2
709
+ points.forEach((p, i) => {
710
+ if (i === 0) ctx.moveTo(p.x, p.y)
711
+ else ctx.lineTo(p.x, p.y)
712
+ })
713
+ ctx.stroke()
714
+
715
+ // Draw data points
716
+ points.forEach((p, i) => {
717
+ ctx.beginPath()
718
+ ctx.fillStyle = i === hoverIndex ? '#ffffff' : '#818cf8'
719
+ ctx.arc(p.x, p.y, i === hoverIndex ? 6 : 3, 0, Math.PI * 2)
720
+ ctx.fill()
721
+ if (i === hoverIndex) {
722
+ ctx.strokeStyle = '#818cf8'
723
+ ctx.lineWidth = 2
724
+ ctx.stroke()
725
+ }
726
+ })
727
+
728
+ // Draw Y axis labels
729
+ ctx.fillStyle = '#6b7280'
730
+ ctx.font = '11px -apple-system, sans-serif'
731
+ ctx.textAlign = 'right'
732
+ for (let i = 0; i <= 4; i++) {
733
+ const value = Math.round(maxViews - (maxViews / 4) * i)
734
+ const y = padding.top + (height / 4) * i
735
+ ctx.fillText(formatNumber(value), padding.left - 10, y + 4)
736
+ }
737
+
738
+ // Draw X axis labels
739
+ ctx.textAlign = 'center'
740
+ ctx.fillStyle = '#6b7280'
741
+ const labelCount = Math.min(timeSeriesData.length, 7)
742
+ const step = Math.max(1, Math.floor((timeSeriesData.length - 1) / (labelCount - 1)))
743
+ for (let i = 0; i < timeSeriesData.length; i += step) {
744
+ const d = timeSeriesData[i]
745
+ const x = padding.left + i * xScale
746
+ const dateStr = formatChartDate(d.date)
747
+ ctx.fillText(dateStr, x, canvas.height - 10)
748
+ }
749
+ // Always show last label if not already shown
750
+ if ((timeSeriesData.length - 1) % step !== 0) {
751
+ const d = timeSeriesData[timeSeriesData.length - 1]
752
+ const x = padding.left + (timeSeriesData.length - 1) * xScale
753
+ ctx.fillText(formatChartDate(d.date), x, canvas.height - 10)
754
+ }
755
+
756
+ // Draw vertical hover line
757
+ if (hoverIndex >= 0) {
758
+ ctx.beginPath()
759
+ ctx.strokeStyle = 'rgba(129, 140, 248, 0.5)'
760
+ ctx.lineWidth = 1
761
+ ctx.setLineDash([4, 4])
762
+ ctx.moveTo(points[hoverIndex].x, padding.top)
763
+ ctx.lineTo(points[hoverIndex].x, padding.top + height)
764
+ ctx.stroke()
765
+ ctx.setLineDash([])
766
+ }
767
+ }
768
+
769
+ function formatChartDate(dateStr) {
770
+ const date = new Date(dateStr)
771
+ const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
772
+ return months[date.getMonth()] + ' ' + date.getDate()
773
+ }
774
+
775
+ // Initial draw
776
+ draw()
777
+
778
+ // Hover interaction
779
+ canvas.onmousemove = function(e) {
780
+ const canvasRect = canvas.getBoundingClientRect()
781
+ const mouseX = e.clientX - canvasRect.left
782
+ const mouseY = e.clientY - canvasRect.top
783
+
784
+ // Find closest point
785
+ let closestIdx = -1
786
+ let closestDist = 30 // Max distance to trigger hover
787
+ points.forEach((p, i) => {
788
+ const dist = Math.abs(mouseX - p.x)
789
+ if (dist < closestDist) {
790
+ closestDist = dist
791
+ closestIdx = i
792
+ }
793
+ })
794
+
795
+ if (closestIdx >= 0) {
796
+ const p = points[closestIdx]
797
+ const d = p.data
798
+ tooltip.innerHTML = `
799
+ <div class="tooltip-date">${formatChartDate(d.date)}</div>
800
+ <div class="tooltip-row"><span class="tooltip-dot views"></span>Views: <strong>${formatNumber(d.views)}</strong></div>
801
+ <div class="tooltip-row"><span class="tooltip-dot visitors"></span>Visitors: <strong>${formatNumber(d.visitors || 0)}</strong></div>
802
+ `
803
+ tooltip.style.display = 'block'
804
+ // Position tooltip
805
+ let left = p.x + 10
806
+ if (left + 150 > canvas.width) left = p.x - 160
807
+ tooltip.style.left = left + 'px'
808
+ tooltip.style.top = (p.y - 30) + 'px'
809
+ draw(closestIdx)
810
+ canvas.style.cursor = 'pointer'
811
+ } else {
812
+ tooltip.style.display = 'none'
813
+ draw()
814
+ canvas.style.cursor = 'default'
815
+ }
816
+ }
817
+
818
+ canvas.onmouseleave = function() {
819
+ tooltip.style.display = 'none'
820
+ draw()
821
+ }
822
+ }
823
+
824
+ // Initial load
825
+ document.addEventListener('DOMContentLoaded', () => {
826
+ if (siteId) {
827
+ // Site ID provided - show dashboard directly
828
+ document.getElementById('site-selector-screen').style.display = 'none'
829
+ document.getElementById('dashboard-content').style.display = 'block'
830
+ fetchDashboardData()
831
+ // Refresh realtime every 30 seconds
832
+ setInterval(fetchDashboardData, 30000)
833
+ } else {
834
+ // No site ID - show site selector
835
+ document.getElementById('site-selector-screen').style.display = 'flex'
836
+ document.getElementById('dashboard-content').style.display = 'none'
837
+ fetchAvailableSites()
838
+ }
839
+ })
840
+ </script>
841
+ <style>
842
+ :root {
843
+ --bg-primary: #1a1f2e;
844
+ --bg-secondary: #242938;
845
+ --bg-tertiary: #2d3344;
846
+ --text-primary: #ffffff;
847
+ --text-secondary: #9ca3af;
848
+ --text-muted: #6b7280;
849
+ --accent: #818cf8;
850
+ --accent-hover: #6366f1;
851
+ --success: #10b981;
852
+ --border: #374151;
853
+ --chart-line: #818cf8;
854
+ }
855
+
856
+ * { box-sizing: border-box; margin: 0; padding: 0; }
857
+
858
+ /* ===== Classes referenced by innerHTML (JS-rendered) ===== */
859
+
860
+ /* Site selector states */
861
+ .site-selector-loading,
862
+ .site-selector-error,
863
+ .site-selector-empty {
864
+ display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 3rem; text-align: center; gap: 1rem;
865
+ color: var(--text-muted);
866
+ }
867
+ .site-selector-error button,
868
+ .site-selector-empty button {
869
+ border: none; padding: 0.5rem 1.5rem; border-radius: 0.375rem; cursor: pointer; font-size: 0.875rem; margin-top: 0.5rem;
870
+ background: var(--accent);
871
+ color: white;
872
+ }
873
+ .site-selector-empty h2 {
874
+ font-size: 1.125rem; line-height: 1.75rem; margin: 0;
875
+ color: var(--text-primary);
876
+ }
877
+ .site-selector-grid {
878
+ display: flex; flex-direction: column; gap: 0.75rem;
879
+ }
880
+ .site-card {
881
+ display: flex; align-items: center; gap: 1rem; padding: 1rem 1.25rem; border-radius: 0.5rem; cursor: pointer; transition: all 150ms; text-align: left; width: 100%;
882
+ background: var(--bg-secondary);
883
+ border: 1px solid var(--border);
884
+ }
885
+ .site-card:hover {
886
+ background: var(--bg-tertiary);
887
+ border-color: var(--accent);
888
+ }
889
+ .site-card-icon {
890
+ width: 2.5rem; height: 2.5rem; display: flex; align-items: center; justify-content: center; border-radius: 0.5rem;
891
+ background: var(--bg-tertiary);
892
+ color: var(--accent);
893
+ }
894
+ .site-card-info {
895
+ flex: 1 1 0%; display: flex; flex-direction: column; gap: 0.25rem;
896
+ }
897
+ .site-card-name {
898
+ font-weight: 500;
899
+ color: var(--text-primary);
900
+ }
901
+ .site-card-domain {
902
+ font-size: 0.75rem; line-height: 1rem; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
903
+ color: var(--text-muted);
904
+ }
905
+ .site-card-arrow {
906
+ color: var(--text-muted);
907
+ }
908
+ .site-card:hover .site-card-arrow {
909
+ color: var(--accent);
910
+ }
911
+
912
+ /* Data table (used in innerHTML) */
913
+ .data-table {
914
+ width: 100%;
915
+ font-size: 0.8125rem;
916
+ }
917
+ .data-table th {
918
+ text-align: left; font-weight: 500; padding: 0.5rem 0; text-transform: uppercase; letter-spacing: 0.05em;
919
+ color: var(--text-muted);
920
+ padding-bottom: 0.5rem;
921
+ border-bottom: 1px solid var(--border);
922
+ font-size: 0.6875rem;
923
+ }
924
+ .data-table td {
925
+ border-bottom: 1px solid var(--bg-tertiary);
926
+ padding: 0.625rem 0;
927
+ }
928
+ .data-table tr:last-child td {
929
+ border-bottom: none;
930
+ }
931
+ .data-table .name {
932
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
933
+ color: var(--text-primary);
934
+ max-width: 180px;
935
+ }
936
+ .data-table .value {
937
+ text-align: right;
938
+ color: var(--text-secondary);
939
+ }
940
+
941
+ /* Empty state (used in innerHTML) */
942
+ .empty-state {
943
+ text-align: center; padding: 2rem;
944
+ color: var(--text-muted);
945
+ }
946
+ .empty-state p {
947
+ margin-bottom: 1rem;
948
+ }
949
+ .empty-state a {
950
+ text-decoration: none;
951
+ color: var(--accent);
952
+ }
953
+ .empty-state a:hover {
954
+ text-decoration: underline;
955
+ }
956
+
957
+ /* Chart tooltip (used in innerHTML) */
958
+ .chart-tooltip {
959
+ display: none; position: absolute; border-radius: 0.5rem; pointer-events: none; z-index: 100;
960
+ background: var(--bg-tertiary);
961
+ border: 1px solid var(--border);
962
+ padding: 0.75rem 1rem;
963
+ font-size: 0.8125rem;
964
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
965
+ min-width: 140px;
966
+ }
967
+ .tooltip-date {
968
+ font-size: 0.75rem; line-height: 1rem; margin-bottom: 0.5rem; font-weight: 500;
969
+ color: var(--text-secondary);
970
+ }
971
+ .tooltip-row {
972
+ display: flex; align-items: center; gap: 0.5rem;
973
+ color: var(--text-secondary);
974
+ margin: 0.25rem 0;
975
+ }
976
+ .tooltip-row strong {
977
+ margin-left: auto;
978
+ color: var(--text-primary);
979
+ }
980
+ .tooltip-dot {
981
+ width: 0.5rem; height: 0.5rem; border-radius: 0.125rem;
982
+ }
983
+ .tooltip-dot.views {
984
+ background: var(--accent);
985
+ }
986
+ .tooltip-dot.visitors {
987
+ background: var(--success);
988
+ }
989
+
990
+ /* Heatmap states (used in innerHTML) */
991
+ .heatmap-loading, .heatmap-empty {
992
+ display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 1rem;
993
+ height: 500px;
994
+ color: var(--text-muted);
995
+ }
996
+ .heatmap-stats {
997
+ position: absolute; bottom: 1rem; right: 1rem; padding: 0.5rem 1rem; border-radius: 0.25rem; font-size: 0.75rem; color: white;
998
+ background: rgba(0,0,0,0.7);
999
+ }
1000
+
1001
+ /* Heatmap panel (used in innerHTML) */
1002
+ .heatmap-panel {
1003
+ border-radius: 0.5rem; padding: 1rem;
1004
+ background: var(--bg-secondary);
1005
+ }
1006
+ .heatmap-panel h3 {
1007
+ font-size: 0.875rem; font-weight: 500; margin-bottom: 1rem;
1008
+ color: var(--text-primary);
1009
+ }
1010
+
1011
+ /* Scroll heatmap (used in innerHTML) */
1012
+ .scroll-heatmap {
1013
+ padding: 1rem;
1014
+ }
1015
+ .scroll-stats {
1016
+ display: flex; gap: 2rem; margin-bottom: 1.5rem;
1017
+ }
1018
+ .scroll-stat {
1019
+ display: flex; flex-direction: column;
1020
+ }
1021
+ .scroll-stat-value {
1022
+ font-size: 1.875rem; line-height: 2.25rem; font-weight: 600;
1023
+ color: var(--text-primary);
1024
+ }
1025
+ .scroll-stat-label {
1026
+ font-size: 0.75rem; line-height: 1rem;
1027
+ color: var(--text-muted);
1028
+ }
1029
+ .scroll-bars {
1030
+ display: flex; flex-direction: column; gap: 0.5rem;
1031
+ }
1032
+ .scroll-bar-row {
1033
+ display: flex; align-items: center; gap: 0.75rem;
1034
+ }
1035
+ .scroll-depth-label {
1036
+ font-size: 0.75rem; text-align: right;
1037
+ width: 40px;
1038
+ color: var(--text-muted);
1039
+ }
1040
+ .scroll-bar-track {
1041
+ flex: 1 1 0%; border-radius: 0.25rem; overflow: hidden;
1042
+ height: 24px;
1043
+ background: var(--bg-tertiary);
1044
+ }
1045
+ .scroll-bar-fill {
1046
+ height: 100%; border-radius: 0.25rem; transition: width 300ms;
1047
+ }
1048
+ .scroll-reach-label {
1049
+ font-size: 0.8125rem; font-weight: 500;
1050
+ width: 50px;
1051
+ color: var(--text-primary);
1052
+ }
1053
+ .scroll-legend {
1054
+ display: flex; justify-content: center; gap: 1.5rem; margin-top: 1.5rem; padding-top: 1rem; font-size: 0.75rem;
1055
+ border-top: 1px solid var(--border);
1056
+ color: var(--text-muted);
1057
+ }
1058
+ .legend-dot {
1059
+ display: inline-block; width: 0.625rem; height: 0.625rem; border-radius: 0.125rem; margin-right: 0.375rem;
1060
+ }
1061
+
1062
+ /* Element list (used in innerHTML) */
1063
+ .element-list {
1064
+ display: flex; flex-direction: column; gap: 0.75rem;
1065
+ }
1066
+ .element-item {
1067
+ display: flex; flex-direction: column; gap: 0.375rem;
1068
+ }
1069
+ .element-info {
1070
+ display: flex; align-items: center; gap: 0.5rem;
1071
+ }
1072
+ .element-tag {
1073
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; border-radius: 0.25rem;
1074
+ font-size: 0.6875rem;
1075
+ color: var(--accent);
1076
+ background: var(--bg-tertiary);
1077
+ padding: 0.125rem 0.375rem;
1078
+ }
1079
+ .element-selector {
1080
+ font-size: 0.75rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
1081
+ color: var(--text-secondary);
1082
+ max-width: 200px;
1083
+ }
1084
+ .element-stats {
1085
+ display: flex; align-items: center; gap: 0.5rem;
1086
+ }
1087
+ .element-bar-track {
1088
+ flex: 1 1 0%; border-radius: 0.125rem; overflow: hidden;
1089
+ height: 6px;
1090
+ background: var(--bg-tertiary);
1091
+ }
1092
+ .element-bar-fill {
1093
+ height: 100%; border-radius: 0.125rem;
1094
+ background: var(--accent);
1095
+ }
1096
+ .element-clicks {
1097
+ font-size: 0.75rem; font-weight: 500; text-align: right;
1098
+ color: var(--text-primary);
1099
+ min-width: 40px;
1100
+ }
1101
+
1102
+ /* Insight list (used in innerHTML) */
1103
+ .insight-list {
1104
+ display: flex; flex-direction: column; gap: 0.75rem;
1105
+ }
1106
+ .insight-item {
1107
+ display: flex; justify-content: space-between; align-items: center; padding: 0.5rem 0;
1108
+ border-bottom: 1px solid var(--bg-tertiary);
1109
+ }
1110
+ .insight-item:last-child {
1111
+ border-bottom: none;
1112
+ }
1113
+ .insight-label {
1114
+ font-size: 0.8125rem;
1115
+ color: var(--text-secondary);
1116
+ }
1117
+ .insight-value {
1118
+ font-size: 0.875rem; font-weight: 500;
1119
+ color: var(--text-primary);
1120
+ }
1121
+
1122
+ /* text-muted helper (used in innerHTML) */
1123
+ .text-muted {
1124
+ font-size: 0.8125rem;
1125
+ color: var(--text-muted);
1126
+ }
1127
+
1128
+ /* View tab active state (toggled by JS classList) */
1129
+ .view-tab.active {
1130
+ background: var(--accent);
1131
+ border-color: var(--accent);
1132
+ color: white;
1133
+ }
1134
+
1135
+ /* Heatmap mode button active state (toggled by JS classList) */
1136
+ .heatmap-mode-btn.active {
1137
+ background: var(--accent);
1138
+ color: white;
1139
+ }
1140
+
1141
+ /* Tab button active state (toggled by JS classList) */
1142
+ .tab-btn.active {
1143
+ color: var(--accent);
1144
+ border-bottom-color: var(--accent);
1145
+ }
1146
+
1147
+ /* Pulse animation */
1148
+ @keyframes pulse {
1149
+ 0%, 100% { opacity: 1; }
1150
+ 50% { opacity: 0.5; }
1151
+ }
1152
+
1153
+ /* Responsive */
1154
+ @media (max-width: 1024px) {
1155
+ .summary-stats {
1156
+ grid-template-columns: repeat(3, 1fr);
1157
+ }
1158
+ .data-grid {
1159
+ grid-template-columns: 1fr;
1160
+ }
1161
+ .heatmap-container {
1162
+ grid-template-columns: 1fr;
1163
+ }
1164
+ .heatmap-sidebar {
1165
+ order: -1;
1166
+ }
1167
+ }
1168
+ @media (max-width: 640px) {
1169
+ .summary-stats {
1170
+ grid-template-columns: repeat(2, 1fr);
1171
+ }
1172
+ .header {
1173
+ flex-direction: column;
1174
+ gap: 1rem;
1175
+ }
1176
+ .heatmap-controls {
1177
+ flex-direction: column;
1178
+ align-items: stretch;
1179
+ }
1180
+ .heatmap-controls select {
1181
+ max-width: none;
1182
+ }
1183
+ }
1184
+ </style>
1185
+ </head>
1186
+ <body class="font-sans leading-normal min-h-screen" style="background:var(--bg-primary);color:var(--text-primary);">
1187
+ <!-- Site Selector Screen (shown when no siteId) -->
1188
+ <div id="site-selector-screen" class="min-h-screen flex flex-col items-center justify-center p-8" style="display:none">
1189
+ <div class="text-center mb-8">
1190
+ <h1 class="text-2xl font-semibold mb-2" style="color:var(--text-primary);">Analytics Dashboard</h1>
1191
+ <p class="text-sm" style="color:var(--text-secondary);">Select a site to view analytics</p>
1192
+ </div>
1193
+ <div id="site-selector-container" class="w-full max-w-[600px]">
1194
+ <div class="site-selector-loading">
1195
+ <div class="spinner"></div>
1196
+ <p>Loading sites...</p>
1197
+ </div>
1198
+ </div>
1199
+ </div>
1200
+
1201
+ <!-- Dashboard Content -->
1202
+ <div id="dashboard-content" class="max-w-[1400px] mx-auto p-4" style="display:none">
1203
+ <!-- Header -->
1204
+ <header class="header flex justify-between items-center py-4 border-b border-border mb-4">
1205
+ <div class="site-selector flex items-center gap-2">
1206
+ <select class="py-2 px-4 rounded-md text-sm cursor-pointer" style="background:var(--bg-secondary);border:1px solid var(--border);color:var(--text-primary);">
1207
+ <option>{{ siteName }}</option>
1208
+ </select>
1209
+ </div>
1210
+ <div class="flex items-center gap-4">
1211
+ <div class="flex items-center gap-2 py-2 px-4 rounded-full text-sm" style="background:var(--bg-secondary);">
1212
+ <span class="w-2 h-2 rounded-full" style="background:var(--success);animation:pulse 2s infinite;"></span>
1213
+ <span id="realtime-count">0 current visitors</span>
1214
+ </div>
1215
+ <button class="icon-btn" title="Notifications">
1216
+ <Icon name="bell" size="20" />
1217
+ </button>
1218
+ <button class="icon-btn" title="Settings">
1219
+ <Icon name="settings" size="20" />
1220
+ </button>
1221
+ </div>
1222
+ </header>
1223
+
1224
+ <!-- View Tabs -->
1225
+ <div class="flex gap-2 mb-4">
1226
+ <button class="view-tab py-2 px-4 rounded-md text-sm cursor-pointer transition-all duration-150 border active" style="background:var(--bg-secondary);border-color:var(--border);color:var(--text-secondary);" data-view="dashboard" onclick="switchView('dashboard')">
1227
+ <Icon name="bar-chart-3" size="16" class="inline align-middle mr-1.5" />
1228
+ Dashboard
1229
+ </button>
1230
+ <button class="view-tab py-2 px-4 rounded-md text-sm cursor-pointer transition-all duration-150 border" style="background:var(--bg-secondary);border-color:var(--border);color:var(--text-secondary);" data-view="heatmap" onclick="switchView('heatmap')">
1231
+ <Icon name="flame" size="16" class="inline align-middle mr-1.5" />
1232
+ Heatmaps
1233
+ </button>
1234
+ </div>
1235
+
1236
+ <!-- Dashboard View -->
1237
+ <div id="dashboard-view">
1238
+
1239
+ <!-- Date Range Bar -->
1240
+ <div class="flex items-center gap-4 py-3 mb-4">
1241
+ <select class="py-2 px-4 rounded-md text-sm cursor-pointer" style="background:var(--bg-secondary);border:1px solid var(--border);color:var(--text-primary);">
1242
+ <option>Last 30 days</option>
1243
+ <option>Last 7 days</option>
1244
+ <option>Today</option>
1245
+ <option>Yesterday</option>
1246
+ <option>Last 12 months</option>
1247
+ <option>Custom range</option>
1248
+ </select>
1249
+ <span class="text-sm" style="color:var(--text-secondary);">compared to</span>
1250
+ <select class="py-2 px-4 rounded-md text-sm cursor-pointer" style="background:var(--bg-secondary);border:1px solid var(--border);color:var(--text-primary);">
1251
+ <option>No comparison</option>
1252
+ <option>Previous period</option>
1253
+ <option>Same period last year</option>
1254
+ </select>
1255
+ <select class="py-2 px-4 rounded-md text-sm cursor-pointer" style="background:var(--bg-secondary);border:1px solid var(--border);color:var(--text-primary);">
1256
+ <option>Auto</option>
1257
+ <option>Hourly</option>
1258
+ <option>Daily</option>
1259
+ <option>Monthly</option>
1260
+ </select>
1261
+ </div>
1262
+
1263
+ <!-- Summary Stats -->
1264
+ <div class="summary-stats grid grid-cols-6 gap-4 mb-6">
1265
+ <div class="stat-card text-center" style="background:var(--bg-secondary);padding:1.25rem;border-radius:8px;">
1266
+ <div class="text-3xl font-semibold mb-1" style="color:var(--text-primary);" id="stat-realtime">0</div>
1267
+ <div class="text-xs uppercase tracking-wider" style="color:var(--text-secondary);">Realtime</div>
1268
+ </div>
1269
+ <div class="stat-card text-center" style="background:var(--bg-secondary);padding:1.25rem;border-radius:8px;">
1270
+ <div class="text-3xl font-semibold mb-1" style="color:var(--text-primary);" id="stat-people">0</div>
1271
+ <div class="text-xs uppercase tracking-wider" style="color:var(--text-secondary);">People</div>
1272
+ </div>
1273
+ <div class="stat-card text-center" style="background:var(--bg-secondary);padding:1.25rem;border-radius:8px;">
1274
+ <div class="text-3xl font-semibold mb-1" style="color:var(--text-primary);" id="stat-views">0</div>
1275
+ <div class="text-xs uppercase tracking-wider" style="color:var(--text-secondary);">Views</div>
1276
+ </div>
1277
+ <div class="stat-card text-center" style="background:var(--bg-secondary);padding:1.25rem;border-radius:8px;">
1278
+ <div class="text-3xl font-semibold mb-1" style="color:var(--text-primary);" id="stat-avgtime">00:00</div>
1279
+ <div class="text-xs uppercase tracking-wider" style="color:var(--text-secondary);">Avg time</div>
1280
+ </div>
1281
+ <div class="stat-card text-center" style="background:var(--bg-secondary);padding:1.25rem;border-radius:8px;">
1282
+ <div class="text-3xl font-semibold mb-1" style="color:var(--text-primary);" id="stat-bounce">0%</div>
1283
+ <div class="text-xs uppercase tracking-wider" style="color:var(--text-secondary);">Bounce</div>
1284
+ </div>
1285
+ <div class="stat-card text-center" style="background:var(--bg-secondary);padding:1.25rem;border-radius:8px;">
1286
+ <div class="text-3xl font-semibold mb-1" style="color:var(--text-primary);" id="stat-events">0</div>
1287
+ <div class="text-xs uppercase tracking-wider" style="color:var(--text-secondary);">Events</div>
1288
+ </div>
1289
+ </div>
1290
+
1291
+ <!-- Time Series Chart -->
1292
+ <div class="rounded-lg p-6 mb-6 min-h-[250px] relative" style="background:var(--bg-secondary);">
1293
+ <canvas id="timeSeriesChart"></canvas>
1294
+ <div id="chartTooltip" class="chart-tooltip"></div>
1295
+ </div>
1296
+
1297
+ <!-- Data Grid - Row 1 -->
1298
+ <div class="data-grid grid grid-cols-2 gap-4 mb-6">
1299
+ <!-- Pages -->
1300
+ <div class="rounded-lg p-4" style="background:var(--bg-secondary);">
1301
+ <div class="panel-header flex items-center gap-2 mb-4">
1302
+ <span class="panel-title text-sm font-medium" style="color:var(--text-primary);">Pages</span>
1303
+ </div>
1304
+ <table class="data-table">
1305
+ <thead>
1306
+ <tr>
1307
+ <th>Path</th>
1308
+ <th style="text-align:right">Entries</th>
1309
+ <th style="text-align:right">Visitors</th>
1310
+ <th style="text-align:right">Views</th>
1311
+ </tr>
1312
+ </thead>
1313
+ <tbody id="pages-body">
1314
+ <tr>
1315
+ <td colspan="4" style="text-align:center;color:var(--text-muted);padding:1rem">Loading...</td>
1316
+ </tr>
1317
+ </tbody>
1318
+ </table>
1319
+ </div>
1320
+
1321
+ <!-- Referrers / Sources -->
1322
+ <div class="rounded-lg p-4" style="background:var(--bg-secondary);">
1323
+ <div class="tabs flex gap-1 border-b border-border mb-4">
1324
+ <button class="tab-btn active">Referrers</button>
1325
+ <button class="tab-btn" data-tab="sources">Sources</button>
1326
+ </div>
1327
+ <table class="data-table">
1328
+ <thead>
1329
+ <tr>
1330
+ <th>Source</th>
1331
+ <th style="text-align:right">Visitors</th>
1332
+ <th style="text-align:right">Views</th>
1333
+ </tr>
1334
+ </thead>
1335
+ <tbody id="referrers-body">
1336
+ <tr>
1337
+ <td colspan="3" style="text-align:center;color:var(--text-muted);padding:1rem">Loading...</td>
1338
+ </tr>
1339
+ </tbody>
1340
+ </table>
1341
+ </div>
1342
+ </div>
1343
+
1344
+ <!-- Data Grid - Row 2 -->
1345
+ <div class="data-grid grid grid-cols-2 gap-4 mb-6">
1346
+ <!-- Device Types / OS -->
1347
+ <div class="rounded-lg p-4" style="background:var(--bg-secondary);">
1348
+ <div class="tabs flex gap-1 border-b border-border mb-4">
1349
+ <button class="tab-btn active" data-tab="types">Device Types</button>
1350
+ <button class="tab-btn" data-tab="os">OS</button>
1351
+ </div>
1352
+ <table class="data-table">
1353
+ <thead>
1354
+ <tr>
1355
+ <th>Type</th>
1356
+ <th style="text-align:right">Visitors</th>
1357
+ </tr>
1358
+ </thead>
1359
+ <tbody id="devices-body">
1360
+ <tr>
1361
+ <td colspan="2" style="text-align:center;color:var(--text-muted);padding:1rem">Loading...</td>
1362
+ </tr>
1363
+ </tbody>
1364
+ </table>
1365
+ </div>
1366
+
1367
+ <!-- Browsers -->
1368
+ <div class="rounded-lg p-4" style="background:var(--bg-secondary);">
1369
+ <div class="panel-header flex items-center gap-2 mb-4">
1370
+ <span class="panel-title text-sm font-medium" style="color:var(--text-primary);">Browsers</span>
1371
+ </div>
1372
+ <table class="data-table">
1373
+ <thead>
1374
+ <tr>
1375
+ <th>Browser</th>
1376
+ <th style="text-align:right">Visitors</th>
1377
+ </tr>
1378
+ </thead>
1379
+ <tbody id="browsers-body">
1380
+ <tr>
1381
+ <td colspan="2" style="text-align:center;color:var(--text-muted);padding:1rem">Loading...</td>
1382
+ </tr>
1383
+ </tbody>
1384
+ </table>
1385
+ </div>
1386
+ </div>
1387
+
1388
+ <!-- Data Grid - Row 3 -->
1389
+ <div class="data-grid grid grid-cols-2 gap-4 mb-6">
1390
+ <!-- Countries / Regions / Cities -->
1391
+ <div class="rounded-lg p-4" style="background:var(--bg-secondary);">
1392
+ <div class="tabs flex gap-1 border-b border-border mb-4">
1393
+ <button class="tab-btn active" data-tab="countries">Countries</button>
1394
+ <button class="tab-btn" data-tab="regions">Regions</button>
1395
+ <button class="tab-btn" data-tab="cities">Cities</button>
1396
+ </div>
1397
+ <table class="data-table">
1398
+ <thead>
1399
+ <tr>
1400
+ <th>Country</th>
1401
+ <th style="text-align:right">Visitors</th>
1402
+ </tr>
1403
+ </thead>
1404
+ <tbody id="countries-body">
1405
+ <tr>
1406
+ <td colspan="2" style="text-align:center;color:var(--text-muted);padding:1rem">Loading...</td>
1407
+ </tr>
1408
+ </tbody>
1409
+ </table>
1410
+ </div>
1411
+
1412
+ <!-- Campaigns -->
1413
+ <div class="rounded-lg p-4" style="background:var(--bg-secondary);">
1414
+ <div class="tabs flex gap-1 border-b border-border mb-4">
1415
+ <button class="tab-btn active" data-tab="campaign">Campaign</button>
1416
+ <button class="tab-btn" data-tab="source">Source</button>
1417
+ <button class="tab-btn" data-tab="medium">Medium</button>
1418
+ <button class="tab-btn" data-tab="content">Content</button>
1419
+ <button class="tab-btn" data-tab="term">Term</button>
1420
+ </div>
1421
+ <table class="data-table">
1422
+ <thead>
1423
+ <tr>
1424
+ <th>Campaign</th>
1425
+ <th style="text-align:right">Visitors</th>
1426
+ <th style="text-align:right">Views</th>
1427
+ </tr>
1428
+ </thead>
1429
+ <tbody id="campaigns-body">
1430
+ <tr>
1431
+ <td colspan="3" style="text-align:center;color:var(--text-muted);padding:1rem">Loading...</td>
1432
+ </tr>
1433
+ </tbody>
1434
+ </table>
1435
+ </div>
1436
+ </div>
1437
+
1438
+ <!-- Events Section -->
1439
+ <div class="rounded-lg p-6" style="background:var(--bg-secondary);">
1440
+ <div class="flex justify-between items-center mb-4">
1441
+ <span class="text-sm font-medium">Events</span>
1442
+ </div>
1443
+ <div id="events-container">
1444
+ <div class="empty-state">
1445
+ <p>Loading events...</p>
1446
+ </div>
1447
+ </div>
1448
+ </div>
1449
+
1450
+ </div><!-- End Dashboard View -->
1451
+
1452
+ <!-- Heatmap View -->
1453
+ <div id="heatmap-view" style="display:none">
1454
+ <div class="heatmap-container grid gap-4" style="grid-template-columns:1fr 320px;">
1455
+ <div class="heatmap-main flex flex-col gap-4">
1456
+ <!-- Controls -->
1457
+ <div class="heatmap-controls flex items-center gap-4 p-4 rounded-lg" style="background:var(--bg-secondary);">
1458
+ <select id="heatmap-page-select" class="flex-1 max-w-[400px] py-2 px-4 rounded-md text-sm" style="background:var(--bg-tertiary);border:1px solid var(--border);color:var(--text-primary);" onchange="selectHeatmapPage(this.value)">
1459
+ <option value="">Select a page...</option>
1460
+ </select>
1461
+ <div class="flex gap-1 p-1 rounded-md" style="background:var(--bg-tertiary);">
1462
+ <button class="heatmap-mode-btn py-2 px-4 rounded text-[0.8125rem] cursor-pointer transition-all duration-150 border-none active" style="background:transparent;color:var(--text-secondary);" data-mode="click" onclick="switchHeatmapMode('click')">
1463
+ <Icon name="mouse-pointer-click" size="14" class="inline align-middle mr-1" />
1464
+ Clicks
1465
+ </button>
1466
+ <button class="heatmap-mode-btn py-2 px-4 rounded text-[0.8125rem] cursor-pointer transition-all duration-150 border-none" style="background:transparent;color:var(--text-secondary);" data-mode="scroll" onclick="switchHeatmapMode('scroll')">
1467
+ <Icon name="arrow-down" size="14" class="inline align-middle mr-1" />
1468
+ Scroll
1469
+ </button>
1470
+ </div>
1471
+ <button class="icon-btn" title="Refresh" onclick="fetchHeatmapData()">
1472
+ <Icon name="refresh-cw" size="18" />
1473
+ </button>
1474
+ </div>
1475
+
1476
+ <!-- Visualization -->
1477
+ <div class="rounded-lg p-4 min-h-[540px]" style="background:var(--bg-secondary);">
1478
+ <div id="heatmap-viz">
1479
+ <div class="heatmap-empty">
1480
+ <Icon name="flame" size="48" />
1481
+ <p>Select a page to view its heatmap</p>
1482
+ </div>
1483
+ </div>
1484
+ </div>
1485
+ </div>
1486
+
1487
+ <!-- Sidebar -->
1488
+ <div class="heatmap-sidebar flex flex-col gap-4" id="heatmap-sidebar">
1489
+ <div class="heatmap-panel">
1490
+ <h3>Getting Started</h3>
1491
+ <p class="text-muted">Select a page from the dropdown to view click or scroll heatmaps. Heatmap data is collected from user interactions on your site.</p>
1492
+ </div>
1493
+ </div>
1494
+ </div>
1495
+ </div>
1496
+
1497
+ </div><!-- End dashboard-content -->
1498
+
1499
+ <script>
1500
+ // Tab switching logic
1501
+ document.querySelectorAll('.tabs').forEach(tabContainer => {
1502
+ const buttons = tabContainer.querySelectorAll('.tab-btn')
1503
+ buttons.forEach(btn => {
1504
+ btn.addEventListener('click', () => {
1505
+ buttons.forEach(b => b.classList.remove('active'))
1506
+ btn.classList.add('active')
1507
+ })
1508
+ })
1509
+ })
1510
+
1511
+ // Handle window resize for chart
1512
+ window.addEventListener('resize', () => {
1513
+ if (timeSeriesData.length) renderChart()
1514
+ })
1515
+ </script>
1516
+ </body>
1517
+ </html>