@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,177 @@
1
+ <script server>
2
+ /**
3
+ * AlertCard Component
4
+ *
5
+ * Displays alerts for threshold breaches or important notifications.
6
+ */
7
+ import { defineProps, withDefaults } from 'stx'
8
+ import { formatCompact } from '../utils'
9
+
10
+ export type AlertSeverity = 'info' | 'success' | 'warning' | 'danger'
11
+
12
+ export interface Alert {
13
+ id: string
14
+ title: string
15
+ message: string
16
+ severity: AlertSeverity
17
+ metric?: string
18
+ value?: number
19
+ threshold?: number
20
+ timestamp?: Date
21
+ }
22
+
23
+ interface AlertCardProps {
24
+ alerts: Alert[]
25
+ title?: string
26
+ loading?: boolean
27
+ maxAlerts?: number
28
+ dismissible?: boolean
29
+ }
30
+
31
+ const { alerts, title, loading, maxAlerts, dismissible } = withDefaults(
32
+ defineProps<AlertCardProps>(),
33
+ {
34
+ title: 'Alerts',
35
+ loading: false,
36
+ maxAlerts: 5,
37
+ dismissible: true,
38
+ }
39
+ )
40
+
41
+ const displayedAlerts = alerts.slice(0, maxAlerts)
42
+
43
+ const severityConfig: Record<AlertSeverity, { bg: string, border: string, icon: string, text: string }> = {
44
+ info: {
45
+ bg: 'bg-blue-50',
46
+ border: 'border-blue-200',
47
+ icon: 'text-blue-500',
48
+ text: 'text-blue-800',
49
+ },
50
+ success: {
51
+ bg: 'bg-green-50',
52
+ border: 'border-green-200',
53
+ icon: 'text-green-500',
54
+ text: 'text-green-800',
55
+ },
56
+ warning: {
57
+ bg: 'bg-amber-50',
58
+ border: 'border-amber-200',
59
+ icon: 'text-amber-500',
60
+ text: 'text-amber-800',
61
+ },
62
+ danger: {
63
+ bg: 'bg-red-50',
64
+ border: 'border-red-200',
65
+ icon: 'text-red-500',
66
+ text: 'text-red-800',
67
+ },
68
+ }
69
+
70
+ const severityIcons: Record<AlertSeverity, string> = {
71
+ info: 'M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z',
72
+ success: 'M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z',
73
+ warning: 'M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z',
74
+ danger: 'M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z',
75
+ }
76
+
77
+ function formatTimestamp(date?: Date): string {
78
+ if (!date)
79
+ return ''
80
+ const now = new Date()
81
+ const diff = now.getTime() - date.getTime()
82
+ const minutes = Math.floor(diff / 60000)
83
+
84
+ if (minutes < 1)
85
+ return 'Just now'
86
+ if (minutes < 60)
87
+ return `${minutes}m ago`
88
+ const hours = Math.floor(minutes / 60)
89
+ if (hours < 24)
90
+ return `${hours}h ago`
91
+ const days = Math.floor(hours / 24)
92
+ return `${days}d ago`
93
+ }
94
+ </script>
95
+
96
+ <div class="alert-card card-hover p-6">
97
+ <div class="flex items-center justify-between mb-4">
98
+ <h3 class="text-sm font-medium text-gray-900">{{ title }}</h3>
99
+ @if (alerts.length > maxAlerts)
100
+ <span class="text-xs text-gray-500">
101
+ +{{ alerts.length - maxAlerts }} more
102
+ </span>
103
+ @endif
104
+ </div>
105
+
106
+ @if (loading)
107
+ <div class="space-y-3 animate-pulse">
108
+ @foreach ([1, 2, 3] as i)
109
+ <div class="skeleton h-16 rounded-lg"></div>
110
+ @endforeach
111
+ </div>
112
+ @elseif (!alerts.length)
113
+ <div class="text-center py-8">
114
+ <Icon name="check-circle" size="48" class="mx-auto text-green-300 mb-3" />
115
+ <p class="text-gray-500">All systems normal</p>
116
+ <p class="text-sm text-gray-400 mt-1">No alerts at this time</p>
117
+ </div>
118
+ @else
119
+ <script client>
120
+ const dismissedAlerts = state([])
121
+ </script>
122
+ <div class="space-y-3">
123
+ @foreach (displayedAlerts as alert)
124
+ <div
125
+ class="p-3 rounded-lg border cursor-pointer transition-all hover:shadow-sm {{ severityConfig[alert.severity].bg }} {{ severityConfig[alert.severity].border }}"
126
+ :show="!dismissedAlerts().includes('{{ alert.id }}')"
127
+ >
128
+ <div class="flex items-start gap-3">
129
+ <svg
130
+ class="w-5 h-5 flex-shrink-0 mt-0.5 {{ severityConfig[alert.severity].icon }}"
131
+ fill="none"
132
+ stroke="currentColor"
133
+ viewBox="0 0 24 24"
134
+ >
135
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="{{ severityIcons[alert.severity] }}" />
136
+ </svg>
137
+
138
+ <div class="flex-1 min-w-0">
139
+ <div class="flex items-start justify-between gap-2">
140
+ <h4 class="text-sm font-medium {{ severityConfig[alert.severity].text }}">
141
+ {{ alert.title }}
142
+ </h4>
143
+ @if (dismissible)
144
+ <button
145
+ type="button"
146
+ class="flex-shrink-0 p-1 rounded hover:bg-black/5 transition-colors {{ severityConfig[alert.severity].text }}"
147
+ @click.stop="dismissedAlerts.set([...dismissedAlerts(), '{{ alert.id }}'])"
148
+ >
149
+ <Icon name="x" size="16" />
150
+ </button>
151
+ @endif
152
+ </div>
153
+
154
+ <p class="text-sm mt-0.5 {{ severityConfig[alert.severity].text }}" style="opacity: 0.8">
155
+ {{ alert.message }}
156
+ </p>
157
+
158
+ @if (alert.value !== undefined)
159
+ <div class="flex items-center gap-4 mt-2 text-xs {{ severityConfig[alert.severity].text }}" style="opacity: 0.7">
160
+ @if (alert.metric)
161
+ <span>{{ alert.metric }}: {{ formatCompact(alert.value) }}</span>
162
+ @endif
163
+ @if (alert.threshold)
164
+ <span>Threshold: {{ formatCompact(alert.threshold) }}</span>
165
+ @endif
166
+ @if (alert.timestamp)
167
+ <span>{{ formatTimestamp(alert.timestamp) }}</span>
168
+ @endif
169
+ </div>
170
+ @endif
171
+ </div>
172
+ </div>
173
+ </div>
174
+ @endforeach
175
+ </div>
176
+ @endif
177
+ </div>
@@ -0,0 +1,354 @@
1
+ <script server>
2
+ /**
3
+ * AnalyticsDashboard Component
4
+ *
5
+ * Main dashboard layout combining all analytics components.
6
+ */
7
+ import { defineProps, withDefaults } from 'stx'
8
+ import type {
9
+ AnalyticsApiConfig,
10
+ DashboardSummary,
11
+ TimeSeriesDataPoint,
12
+ TopItem,
13
+ } from '../types'
14
+
15
+ interface AnalyticsDashboardProps {
16
+ config: AnalyticsApiConfig
17
+ refreshInterval?: number
18
+ realtimeInterval?: number
19
+ stats?: DashboardSummary | null
20
+ timeSeries?: TimeSeriesDataPoint[]
21
+ topPages?: TopItem[]
22
+ topReferrers?: TopItem[]
23
+ devices?: TopItem[]
24
+ realtimeVisitors?: number
25
+ realtimePageViews?: number
26
+ loading?: boolean
27
+ error?: string | null
28
+ }
29
+
30
+ const {
31
+ config,
32
+ refreshInterval,
33
+ realtimeInterval,
34
+ stats,
35
+ timeSeries,
36
+ topPages,
37
+ topReferrers,
38
+ devices,
39
+ realtimeVisitors,
40
+ realtimePageViews,
41
+ loading,
42
+ error,
43
+ } = withDefaults(
44
+ defineProps<AnalyticsDashboardProps>(),
45
+ {
46
+ refreshInterval: 60000,
47
+ realtimeInterval: 5000,
48
+ stats: null,
49
+ timeSeries: () => [],
50
+ topPages: () => [],
51
+ topReferrers: () => [],
52
+ devices: () => [],
53
+ realtimeVisitors: 0,
54
+ realtimePageViews: 0,
55
+ loading: false,
56
+ error: null,
57
+ }
58
+ )
59
+ </script>
60
+
61
+ <script client>
62
+ const isLoading = state({{ loading ? 'true' : 'false' }})
63
+ const dateRange = state('7d')
64
+
65
+ function refresh() {
66
+ isLoading.set(true)
67
+ document.dispatchEvent(new CustomEvent('refresh'))
68
+ }
69
+ </script>
70
+ <div
71
+ class="analytics-dashboard min-h-screen bg-gray-50 p-6"
72
+ style="max-width: 1400px; margin: 0 auto"
73
+ >
74
+ <header class="flex items-center justify-between mb-6">
75
+ <div>
76
+ <h1 class="text-2xl font-bold text-gray-900">
77
+ Analytics Dashboard
78
+ </h1>
79
+ <p class="text-gray-500 text-sm mt-1">
80
+ Track your website performance
81
+ </p>
82
+ </div>
83
+
84
+ <div class="flex items-center gap-4">
85
+ <select
86
+ class="bg-white border border-gray-200 text-gray-900 px-4 py-2 rounded-lg text-sm cursor-pointer hover:border-indigo-500"
87
+ :model="dateRange"
88
+ @change="document.dispatchEvent(new CustomEvent('date-change', { detail: { range: dateRange() } }))"
89
+ >
90
+ <option value="today">Today</option>
91
+ <option value="yesterday">Yesterday</option>
92
+ <option value="7d">Last 7 days</option>
93
+ <option value="30d">Last 30 days</option>
94
+ <option value="90d">Last 90 days</option>
95
+ </select>
96
+
97
+ <button
98
+ type="button"
99
+ class="btn-icon p-2 rounded-lg hover:bg-gray-100"
100
+ title="Refresh"
101
+ x-disabled="isLoading"
102
+ @click="refresh()"
103
+ >
104
+ <Icon name="refresh-cw" size="20" x-class="{ 'animate-spin': isLoading() }" />
105
+ </button>
106
+ </div>
107
+ </header>
108
+
109
+ @if (error)
110
+ <div class="mb-6 p-4 bg-red-50 border border-red-200 rounded-lg text-red-700">
111
+ <p class="font-medium">Error loading analytics</p>
112
+ <p class="text-sm mt-1">{{ error }}</p>
113
+ <button
114
+ type="button"
115
+ class="mt-2 text-sm text-red-600 hover:text-red-800 underline"
116
+ @click="refresh()"
117
+ >
118
+ Try again
119
+ </button>
120
+ </div>
121
+ @endif
122
+
123
+ <section class="mb-6">
124
+ <div class="realtime-counter card-hover p-6">
125
+ <div class="flex items-center gap-3">
126
+ <div class="relative">
127
+ <div class="w-3 h-3 rounded-full bg-green-500 animate-pulse"></div>
128
+ <div class="absolute inset-0 w-3 h-3 rounded-full bg-green-500 animate-ping"></div>
129
+ </div>
130
+ <div>
131
+ <span class="text-4xl font-bold text-gray-900">{{ realtimeVisitors }}</span>
132
+ <span class="ml-2 text-gray-500 text-sm">visitors right now</span>
133
+ </div>
134
+ </div>
135
+ @if (realtimePageViews > 0)
136
+ <p class="mt-2 text-sm text-gray-500">
137
+ {{ realtimePageViews }} page views in the last hour
138
+ </p>
139
+ @endif
140
+ </div>
141
+ </section>
142
+
143
+ <section class="dashboard-grid mb-6">
144
+ <div class="stat-card card-hover p-6">
145
+ <h3 class="text-sm font-medium text-gray-500">Page Views</h3>
146
+ @if (loading)
147
+ <div class="animate-pulse"><div class="skeleton h-8 w-24 mt-2"></div></div>
148
+ @else
149
+ <p class="text-3xl font-bold text-gray-900 mt-2">{{ stats?.pageViews ?? 0 }}</p>
150
+ @if (stats?.change?.pageViews !== undefined)
151
+ <p class="text-sm mt-1 {{ stats.change.pageViews >= 0 ? 'text-green-600' : 'text-red-600' }}">
152
+ {{ stats.change.pageViews >= 0 ? '+' : '' }}{{ stats.change.pageViews.toFixed(1) }}% vs previous period
153
+ </p>
154
+ @endif
155
+ @endif
156
+ </div>
157
+
158
+ <div class="stat-card card-hover p-6">
159
+ <h3 class="text-sm font-medium text-gray-500">Unique Visitors</h3>
160
+ @if (loading)
161
+ <div class="animate-pulse"><div class="skeleton h-8 w-24 mt-2"></div></div>
162
+ @else
163
+ <p class="text-3xl font-bold text-gray-900 mt-2">{{ stats?.uniqueVisitors ?? 0 }}</p>
164
+ @if (stats?.change?.uniqueVisitors !== undefined)
165
+ <p class="text-sm mt-1 {{ stats.change.uniqueVisitors >= 0 ? 'text-green-600' : 'text-red-600' }}">
166
+ {{ stats.change.uniqueVisitors >= 0 ? '+' : '' }}{{ stats.change.uniqueVisitors.toFixed(1) }}% vs previous period
167
+ </p>
168
+ @endif
169
+ @endif
170
+ </div>
171
+
172
+ <div class="stat-card card-hover p-6">
173
+ <h3 class="text-sm font-medium text-gray-500">Bounce Rate</h3>
174
+ @if (loading)
175
+ <div class="animate-pulse"><div class="skeleton h-8 w-24 mt-2"></div></div>
176
+ @else
177
+ <p class="text-3xl font-bold text-gray-900 mt-2">{{ ((stats?.bounceRate ?? 0) * 100).toFixed(1) }}%</p>
178
+ @if (stats?.change?.bounceRate !== undefined)
179
+ <p class="text-sm mt-1 {{ stats.change.bounceRate <= 0 ? 'text-green-600' : 'text-red-600' }}">
180
+ {{ stats.change.bounceRate >= 0 ? '+' : '' }}{{ stats.change.bounceRate.toFixed(1) }}% vs previous period
181
+ </p>
182
+ @endif
183
+ @endif
184
+ </div>
185
+
186
+ <div class="stat-card card-hover p-6">
187
+ <h3 class="text-sm font-medium text-gray-500">Avg. Session Duration</h3>
188
+ @if (loading)
189
+ <div class="animate-pulse"><div class="skeleton h-8 w-24 mt-2"></div></div>
190
+ @else
191
+ <p class="text-3xl font-bold text-gray-900 mt-2">{{ Math.floor((stats?.avgSessionDuration ?? 0) / 60) }}m {{ Math.floor((stats?.avgSessionDuration ?? 0) % 60) }}s</p>
192
+ @if (stats?.change?.avgSessionDuration !== undefined)
193
+ <p class="text-sm mt-1 {{ stats.change.avgSessionDuration >= 0 ? 'text-green-600' : 'text-red-600' }}">
194
+ {{ stats.change.avgSessionDuration >= 0 ? '+' : '' }}{{ stats.change.avgSessionDuration.toFixed(1) }}% vs previous period
195
+ </p>
196
+ @endif
197
+ @endif
198
+ </div>
199
+ </section>
200
+
201
+ <section class="mb-6">
202
+ <div class="card-hover p-6">
203
+ <h3 class="text-sm font-medium text-gray-900 mb-4">Traffic Overview</h3>
204
+ @if (loading)
205
+ <div class="animate-pulse"><div class="skeleton h-80 w-full"></div></div>
206
+ @elseif (timeSeries.length === 0)
207
+ <div class="flex items-center justify-center h-80 text-gray-400">
208
+ <p>No data available for selected period</p>
209
+ </div>
210
+ @else
211
+ <script client>
212
+ onMount(() => {
213
+ const canvas = document.getElementById('trafficChart')
214
+ const ctx = canvas?.getContext('2d')
215
+ if (!ctx) return
216
+
217
+ const chartData = {{ JSON.stringify(timeSeries) }}
218
+ const labels = chartData.map(d => new Date(d.date).toLocaleDateString())
219
+
220
+ new Chart(ctx, {
221
+ type: 'line',
222
+ data: {
223
+ labels,
224
+ datasets: [
225
+ {
226
+ label: 'Page Views',
227
+ data: chartData.map(d => d.pageViews || d.views || 0),
228
+ borderColor: '#6366f1',
229
+ backgroundColor: '#6366f120',
230
+ fill: true,
231
+ tension: 0.3,
232
+ },
233
+ {
234
+ label: 'Unique Visitors',
235
+ data: chartData.map(d => d.uniqueVisitors || d.visitors || 0),
236
+ borderColor: '#22c55e',
237
+ backgroundColor: '#22c55e20',
238
+ fill: true,
239
+ tension: 0.3,
240
+ }
241
+ ]
242
+ },
243
+ options: {
244
+ responsive: true,
245
+ maintainAspectRatio: false,
246
+ plugins: { legend: { display: true, position: 'top' } },
247
+ scales: {
248
+ y: { beginAtZero: true, grid: { color: '#e5e7eb' } },
249
+ x: { grid: { display: false } }
250
+ }
251
+ }
252
+ })
253
+ })
254
+ </script>
255
+ <canvas
256
+ id="trafficChart"
257
+ style="height: 320px"
258
+ ></canvas>
259
+ @endif
260
+ </div>
261
+ </section>
262
+
263
+ <section class="dashboard-grid-3">
264
+ <div class="card-hover p-6">
265
+ <h3 class="text-sm font-medium text-gray-900 mb-4">Top Pages</h3>
266
+ @if (loading)
267
+ <div class="space-y-3">
268
+ @foreach ([1, 2, 3, 4, 5] as i)
269
+ <div class="animate-pulse">
270
+ <div class="skeleton h-4 w-full mb-1"></div>
271
+ <div class="skeleton h-2 w-3/4"></div>
272
+ </div>
273
+ @endforeach
274
+ </div>
275
+ @elseif (topPages.length === 0)
276
+ <p class="text-gray-500 text-center py-8">No page views yet</p>
277
+ @else
278
+ <div class="space-y-3">
279
+ @foreach (topPages as page)
280
+ <div>
281
+ <div class="flex justify-between text-sm mb-1">
282
+ <span class="text-gray-900 truncate" title="{{ page.name }}">{{ page.name }}</span>
283
+ <span class="text-gray-500">{{ page.value }}</span>
284
+ </div>
285
+ <div class="progress-bar">
286
+ <div class="progress-fill bg-indigo-500" style="width: {{ page.percentage * 100 }}%"></div>
287
+ </div>
288
+ </div>
289
+ @endforeach
290
+ </div>
291
+ @endif
292
+ </div>
293
+
294
+ <div class="card-hover p-6">
295
+ <h3 class="text-sm font-medium text-gray-900 mb-4">Top Referrers</h3>
296
+ @if (loading)
297
+ <div class="space-y-3">
298
+ @foreach ([1, 2, 3, 4, 5] as i)
299
+ <div class="animate-pulse">
300
+ <div class="skeleton h-4 w-full mb-1"></div>
301
+ <div class="skeleton h-2 w-3/4"></div>
302
+ </div>
303
+ @endforeach
304
+ </div>
305
+ @elseif (topReferrers.length === 0)
306
+ <p class="text-gray-500 text-center py-8">No referrer data</p>
307
+ @else
308
+ <div class="space-y-3">
309
+ @foreach (topReferrers as referrer)
310
+ <div>
311
+ <div class="flex justify-between text-sm mb-1">
312
+ <span class="text-gray-900 truncate" title="{{ referrer.name }}">{{ referrer.name }}</span>
313
+ <span class="text-gray-500">{{ referrer.value }}</span>
314
+ </div>
315
+ <div class="progress-bar">
316
+ <div class="progress-fill bg-indigo-500" style="width: {{ referrer.percentage * 100 }}%"></div>
317
+ </div>
318
+ </div>
319
+ @endforeach
320
+ </div>
321
+ @endif
322
+ </div>
323
+
324
+ <div class="card-hover p-6">
325
+ <h3 class="text-sm font-medium text-gray-900 mb-4">Devices</h3>
326
+ @if (loading)
327
+ <div class="space-y-3">
328
+ @foreach ([1, 2, 3] as i)
329
+ <div class="animate-pulse">
330
+ <div class="skeleton h-4 w-full mb-1"></div>
331
+ <div class="skeleton h-2 w-3/4"></div>
332
+ </div>
333
+ @endforeach
334
+ </div>
335
+ @elseif (devices.length === 0)
336
+ <p class="text-gray-500 text-center py-8">No device data</p>
337
+ @else
338
+ <div class="space-y-3">
339
+ @foreach (devices as device)
340
+ <div>
341
+ <div class="flex justify-between text-sm mb-1">
342
+ <span class="text-gray-900">{{ device.name }}</span>
343
+ <span class="text-gray-500">{{ (device.percentage * 100).toFixed(1) }}%</span>
344
+ </div>
345
+ <div class="progress-bar">
346
+ <div class="progress-fill bg-indigo-500" style="width: {{ device.percentage * 100 }}%"></div>
347
+ </div>
348
+ </div>
349
+ @endforeach
350
+ </div>
351
+ @endif
352
+ </div>
353
+ </section>
354
+ </div>
@@ -0,0 +1,86 @@
1
+ <script server>
2
+ /**
3
+ * AnimatedNumber Component
4
+ *
5
+ * Animated counting number with formatting options.
6
+ * Uses Alpine.js for client-side animation.
7
+ */
8
+ import { defineProps, withDefaults } from 'stx'
9
+ import { formatCompact, formatCurrency, formatPercentage } from '../utils'
10
+
11
+ interface AnimatedNumberProps {
12
+ value: number
13
+ format?: 'number' | 'compact' | 'percentage' | 'currency'
14
+ duration?: number
15
+ decimals?: number
16
+ prefix?: string
17
+ suffix?: string
18
+ animate?: boolean
19
+ }
20
+
21
+ const { value, format, duration, decimals, prefix, suffix, animate } = withDefaults(
22
+ defineProps<AnimatedNumberProps>(),
23
+ {
24
+ format: 'compact',
25
+ duration: 1000,
26
+ decimals: 0,
27
+ prefix: '',
28
+ suffix: '',
29
+ animate: true,
30
+ }
31
+ )
32
+
33
+ function formatNumber(num: number): string {
34
+ switch (format) {
35
+ case 'percentage':
36
+ return formatPercentage(num)
37
+ case 'currency':
38
+ return formatCurrency(num)
39
+ case 'compact':
40
+ return formatCompact(num)
41
+ default:
42
+ return num.toLocaleString(undefined, {
43
+ minimumFractionDigits: decimals,
44
+ maximumFractionDigits: decimals,
45
+ })
46
+ }
47
+ }
48
+
49
+ const formattedValue = `${prefix}${formatNumber(value)}${suffix}`
50
+ </script>
51
+
52
+ @if (animate)
53
+ <script client>
54
+ const current = state(0)
55
+ const isAnimating = state(true)
56
+
57
+ function formatNum(n) {
58
+ return '{{ prefix }}' + n.toLocaleString() + '{{ suffix }}'
59
+ }
60
+
61
+ onMount(() => {
62
+ const target = {{ value }}
63
+ const animDuration = {{ duration }}
64
+ const start = performance.now()
65
+ const step = (now) => {
66
+ const elapsed = now - start
67
+ const progress = Math.min(elapsed / animDuration, 1)
68
+ const eased = 1 - Math.pow(1 - progress, 3)
69
+ current.set(Math.round(eased * target))
70
+ if (progress < 1) {
71
+ requestAnimationFrame(step)
72
+ } else {
73
+ isAnimating.set(false)
74
+ }
75
+ }
76
+ requestAnimationFrame(step)
77
+ })
78
+ </script>
79
+ <span
80
+ class="animated-number tabular-nums"
81
+ :text="formatNum(current())"
82
+ x-class="{ 'opacity-80': isAnimating() }"
83
+ >{{ formattedValue }}</span>
84
+ @else
85
+ <span class="animated-number tabular-nums">{{ formattedValue }}</span>
86
+ @endif