@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.
- package/CHANGELOG.md +68 -0
- package/LICENSE.md +21 -0
- package/README.md +361 -0
- package/bin/cli.ts +169 -0
- package/dist/Analytics.d.ts +558 -0
- package/dist/api.d.ts +109 -0
- package/dist/batching.d.ts +93 -0
- package/dist/chunk-2mx7fq49.js +4 -0
- package/dist/chunk-3z29508k.js +204 -0
- package/dist/chunk-deephkz6.js +56 -0
- package/dist/chunk-j261vgyp.js +305 -0
- package/dist/chunk-xga17tz7.js +207 -0
- package/dist/config.d.ts +132 -0
- package/dist/dashboard/components/index.d.ts +80 -0
- package/dist/dashboard/composables/useAnalytics.d.ts +99 -0
- package/dist/dashboard/index.d.ts +110 -0
- package/dist/dashboard/types/index.d.ts +144 -0
- package/dist/dashboard/utils/index.d.ts +81 -0
- package/dist/dynamodb.d.ts +86 -0
- package/dist/funnels.d.ts +104 -0
- package/dist/geolocation.d.ts +96 -0
- package/dist/index.d.ts +371 -0
- package/dist/index.js +10211 -0
- package/dist/infrastructure/cdk.d.ts +60 -0
- package/dist/infrastructure/cloudformation.d.ts +44 -0
- package/dist/infrastructure/index.d.ts +45 -0
- package/dist/infrastructure/setup.d.ts +133 -0
- package/dist/integrations/cloudflare.d.ts +76 -0
- package/dist/integrations/hono.d.ts +60 -0
- package/dist/integrations/index.d.ts +24 -0
- package/dist/integrations/nuxt.d.ts +7 -0
- package/dist/integrations/nuxt.js +42 -0
- package/dist/integrations/runtime/use-ts-analytics.d.ts +18 -0
- package/dist/integrations/runtime/use-ts-analytics.js +17 -0
- package/dist/integrations/stx.d.ts +88 -0
- package/dist/integrations/stx.js +18 -0
- package/dist/lib/crypto-random.d.ts +4 -0
- package/dist/lib/salt.d.ts +16 -0
- package/dist/local.d.ts +56 -0
- package/dist/model-connector.d.ts +145 -0
- package/dist/models/AggregatedStats.d.ts +9 -0
- package/dist/models/CampaignStats.d.ts +9 -0
- package/dist/models/Conversion.d.ts +11 -0
- package/dist/models/CustomEvent.d.ts +11 -0
- package/dist/models/DeviceStats.d.ts +9 -0
- package/dist/models/EventStats.d.ts +9 -0
- package/dist/models/GeoStats.d.ts +9 -0
- package/dist/models/Goal.d.ts +9 -0
- package/dist/models/GoalStats.d.ts +9 -0
- package/dist/models/PageStats.d.ts +11 -0
- package/dist/models/PageView.d.ts +13 -0
- package/dist/models/RealtimeStats.d.ts +9 -0
- package/dist/models/ReferrerStats.d.ts +9 -0
- package/dist/models/Session.d.ts +11 -0
- package/dist/models/Site.d.ts +11 -0
- package/dist/models/index.d.ts +28 -0
- package/dist/models/types.d.ts +60 -0
- package/dist/sqs-buffering.d.ts +243 -0
- package/dist/stacks-integration.d.ts +159 -0
- package/dist/tracking-script.d.ts +71 -0
- package/dist/tracking.d.ts +17 -0
- package/dist/tracking.js +26 -0
- package/dist/types.d.ts +595 -0
- package/dist/utils/geolocation.d.ts +111 -0
- package/dist/utils/user-agent.d.ts +17 -0
- package/dist/version.d.ts +7 -0
- package/package.json +119 -0
- package/src/Analytics.ts +3349 -0
- package/src/api.ts +1286 -0
- package/src/assets/crosswind.css +2220 -0
- package/src/batching.ts +452 -0
- package/src/components/dashboard/index.ts +18 -0
- package/src/config.ts +456 -0
- package/src/dashboard/Dashboard.stx +1517 -0
- package/src/dashboard/components/AlertCard.stx +177 -0
- package/src/dashboard/components/AnalyticsDashboard.stx +354 -0
- package/src/dashboard/components/AnimatedNumber.stx +86 -0
- package/src/dashboard/components/BarChart.stx +220 -0
- package/src/dashboard/components/BrowserBreakdown.stx +98 -0
- package/src/dashboard/components/BrowsersTable.stx +125 -0
- package/src/dashboard/components/CampaignBreakdown.stx +163 -0
- package/src/dashboard/components/CampaignTable.stx +238 -0
- package/src/dashboard/components/CountryList.stx +101 -0
- package/src/dashboard/components/DataTable.stx +226 -0
- package/src/dashboard/components/DateRangePicker.stx +77 -0
- package/src/dashboard/components/DeviceBreakdown.stx +94 -0
- package/src/dashboard/components/DevicesTable.stx +163 -0
- package/src/dashboard/components/DonutChart.stories.ts +55 -0
- package/src/dashboard/components/DonutChart.stx +157 -0
- package/src/dashboard/components/EmptyState.stx +90 -0
- package/src/dashboard/components/EngagementMetrics.stx +183 -0
- package/src/dashboard/components/EventsSection.stx +192 -0
- package/src/dashboard/components/FilterBar.stx +104 -0
- package/src/dashboard/components/FullAnalyticsDashboard.stx +455 -0
- package/src/dashboard/components/FunnelChart.stx +142 -0
- package/src/dashboard/components/GeoTable.stx +337 -0
- package/src/dashboard/components/GoalsPanel.stx +109 -0
- package/src/dashboard/components/Header.stx +39 -0
- package/src/dashboard/components/HeatmapChart.stx +140 -0
- package/src/dashboard/components/LiveActivityFeed.stx +172 -0
- package/src/dashboard/components/MetricComparison.stx +106 -0
- package/src/dashboard/components/MiniStats.stx +96 -0
- package/src/dashboard/components/OSBreakdown.stx +124 -0
- package/src/dashboard/components/PageDetailCard.stx +102 -0
- package/src/dashboard/components/PagesTable.stx +127 -0
- package/src/dashboard/components/ProgressRing.stx +89 -0
- package/src/dashboard/components/RealtimeCounter.stories.ts +45 -0
- package/src/dashboard/components/RealtimeCounter.stx +46 -0
- package/src/dashboard/components/ReferrersTable.stx +180 -0
- package/src/dashboard/components/SparklineChart.stx +160 -0
- package/src/dashboard/components/StatCard.stories.ts +58 -0
- package/src/dashboard/components/StatCard.stx +90 -0
- package/src/dashboard/components/SummaryStats.stx +81 -0
- package/src/dashboard/components/TabNav.stx +66 -0
- package/src/dashboard/components/ThemeSwitcher.stx +124 -0
- package/src/dashboard/components/TimeSeriesChart.stx +106 -0
- package/src/dashboard/components/TopList.stories.ts +58 -0
- package/src/dashboard/components/TopList.stx +74 -0
- package/src/dashboard/components/TrendIndicator.stx +84 -0
- package/src/dashboard/components/heatmap/ClickHeatmap.stx +264 -0
- package/src/dashboard/components/heatmap/ElementClickList.stx +125 -0
- package/src/dashboard/components/heatmap/HeatmapControls.stx +125 -0
- package/src/dashboard/components/heatmap/HeatmapLegend.stx +69 -0
- package/src/dashboard/components/heatmap/PageHeatmap.stx +264 -0
- package/src/dashboard/components/heatmap/ScrollHeatmap.stx +127 -0
- package/src/dashboard/components/heatmap/index.ts +12 -0
- package/src/dashboard/components/index.ts +86 -0
- package/src/dashboard/composables/useAnalytics.ts +465 -0
- package/src/dashboard/demo/DemoApp.stx +370 -0
- package/src/dashboard/demo/index.ts +8 -0
- package/src/dashboard/demo/mockData.ts +234 -0
- package/src/dashboard/index.ts +117 -0
- package/src/dashboard/stx-shim.d.ts +4 -0
- package/src/dashboard/types/index.ts +203 -0
- package/src/dashboard/utils/index.ts +426 -0
- package/src/dynamodb.ts +344 -0
- package/src/funnels.ts +534 -0
- package/src/geolocation.ts +515 -0
- package/src/handlers/alerts.ts +328 -0
- package/src/handlers/annotations.ts +128 -0
- package/src/handlers/api-keys.ts +240 -0
- package/src/handlers/auth.ts +1020 -0
- package/src/handlers/authz.ts +137 -0
- package/src/handlers/collect.ts +724 -0
- package/src/handlers/data.ts +625 -0
- package/src/handlers/experiments.ts +138 -0
- package/src/handlers/funnels.ts +216 -0
- package/src/handlers/goals.ts +218 -0
- package/src/handlers/heatmaps.ts +272 -0
- package/src/handlers/index.ts +56 -0
- package/src/handlers/lib/read-cache.ts +63 -0
- package/src/handlers/misc.ts +486 -0
- package/src/handlers/oauth.ts +233 -0
- package/src/handlers/performance.ts +388 -0
- package/src/handlers/sessions.ts +413 -0
- package/src/handlers/sharing.ts +198 -0
- package/src/handlers/stats.ts +1368 -0
- package/src/handlers/team.ts +161 -0
- package/src/handlers/uptime.ts +283 -0
- package/src/handlers/views.ts +390 -0
- package/src/handlers/webhooks.ts +226 -0
- package/src/heatmap/index.ts +32 -0
- package/src/heatmap/tracking-script.ts +452 -0
- package/src/heatmap/types.ts +79 -0
- package/src/index.ts +387 -0
- package/src/infrastructure/cdk.ts +496 -0
- package/src/infrastructure/cloudformation.ts +595 -0
- package/src/infrastructure/index.ts +48 -0
- package/src/infrastructure/setup.ts +611 -0
- package/src/integrations/cloudflare.ts +732 -0
- package/src/integrations/hono.ts +589 -0
- package/src/integrations/index.ts +27 -0
- package/src/integrations/nuxt.ts +78 -0
- package/src/integrations/runtime/use-ts-analytics.ts +32 -0
- package/src/integrations/stx.ts +138 -0
- package/src/jobs/index.ts +127 -0
- package/src/lib/crypto-random.ts +41 -0
- package/src/lib/ddb-errors.ts +20 -0
- package/src/lib/dynamodb.ts +216 -0
- package/src/lib/email.ts +38 -0
- package/src/lib/ga-import.ts +471 -0
- package/src/lib/ga4-api.ts +244 -0
- package/src/lib/goals.ts +205 -0
- package/src/lib/index.ts +6 -0
- package/src/lib/ingest-counters.ts +123 -0
- package/src/lib/log.ts +36 -0
- package/src/lib/membership.ts +98 -0
- package/src/lib/plans.ts +90 -0
- package/src/lib/quota.ts +46 -0
- package/src/lib/rate-limit.ts +27 -0
- package/src/lib/rollups.ts +472 -0
- package/src/lib/salt.ts +97 -0
- package/src/lib/scheduler.ts +97 -0
- package/src/lib/significance.ts +66 -0
- package/src/lib/site-retention.ts +56 -0
- package/src/local.ts +360 -0
- package/src/model-connector.ts +616 -0
- package/src/models/AggregatedStats.ts +162 -0
- package/src/models/CampaignStats.ts +141 -0
- package/src/models/Conversion.ts +135 -0
- package/src/models/CustomEvent.ts +123 -0
- package/src/models/DeviceStats.ts +105 -0
- package/src/models/EventStats.ts +108 -0
- package/src/models/GeoStats.ts +112 -0
- package/src/models/Goal.ts +105 -0
- package/src/models/GoalStats.ts +106 -0
- package/src/models/PageStats.ts +152 -0
- package/src/models/PageView.ts +277 -0
- package/src/models/RealtimeStats.ts +96 -0
- package/src/models/ReferrerStats.ts +115 -0
- package/src/models/Session.ts +235 -0
- package/src/models/Site.ts +115 -0
- package/src/models/index.ts +50 -0
- package/src/models/orm/index.ts +2043 -0
- package/src/models/types.ts +71 -0
- package/src/router.ts +521 -0
- package/src/sqs-buffering.ts +806 -0
- package/src/stacks-integration.ts +527 -0
- package/src/tracking-script.ts +944 -0
- package/src/tracking.ts +27 -0
- package/src/types/analytics.ts +219 -0
- package/src/types/api.ts +75 -0
- package/src/types/bun-router.d.ts +15 -0
- package/src/types/dashboard.ts +62 -0
- package/src/types/index.ts +66 -0
- package/src/types/stx.d.ts +27 -0
- package/src/types/window.d.ts +101 -0
- package/src/types.ts +911 -0
- package/src/utils/cache.ts +148 -0
- package/src/utils/date.ts +118 -0
- package/src/utils/filters.ts +71 -0
- package/src/utils/geolocation.ts +323 -0
- package/src/utils/index.ts +9 -0
- package/src/utils/response.ts +180 -0
- package/src/utils/timezone-country.ts +242 -0
- package/src/utils/user-agent.ts +110 -0
- package/src/version.ts +7 -0
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
<script server>
|
|
2
|
+
/**
|
|
3
|
+
* StatCard Component
|
|
4
|
+
*
|
|
5
|
+
* Displays a single stat with optional change indicator.
|
|
6
|
+
*/
|
|
7
|
+
import { defineProps, withDefaults } from 'stx'
|
|
8
|
+
|
|
9
|
+
interface StatCardProps {
|
|
10
|
+
title: string
|
|
11
|
+
value: string | number
|
|
12
|
+
format?: 'number' | 'percentage' | 'duration' | 'currency'
|
|
13
|
+
change?: number
|
|
14
|
+
changeLabel?: string
|
|
15
|
+
icon?: string
|
|
16
|
+
loading?: boolean
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const { title, value, format, change, changeLabel, icon, loading } = withDefaults(
|
|
20
|
+
defineProps<StatCardProps>(),
|
|
21
|
+
{
|
|
22
|
+
format: 'number',
|
|
23
|
+
loading: false,
|
|
24
|
+
}
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
function formatValue(val: string | number, fmt: string): string {
|
|
28
|
+
if (typeof val === 'string') return val
|
|
29
|
+
|
|
30
|
+
switch (fmt) {
|
|
31
|
+
case 'percentage':
|
|
32
|
+
return `${val}%`
|
|
33
|
+
case 'duration':
|
|
34
|
+
const mins = Math.floor(val / 60)
|
|
35
|
+
const secs = val % 60
|
|
36
|
+
return `${mins}:${secs.toString().padStart(2, '0')}`
|
|
37
|
+
case 'currency':
|
|
38
|
+
return `$${val.toLocaleString()}`
|
|
39
|
+
case 'number':
|
|
40
|
+
default:
|
|
41
|
+
if (val >= 1e6) return `${(val / 1e6).toFixed(1)}M`
|
|
42
|
+
if (val >= 1e3) return `${(val / 1e3).toFixed(1)}k`
|
|
43
|
+
return val.toLocaleString()
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function formatChange(val: number): string {
|
|
48
|
+
const sign = val >= 0 ? '+' : ''
|
|
49
|
+
return `${sign}${val}%`
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const formattedValue = formatValue(value, format || 'number')
|
|
53
|
+
const formattedChange = change !== undefined ? formatChange(change) : ''
|
|
54
|
+
const isPositiveGood = title.toLowerCase() !== 'bounce rate'
|
|
55
|
+
const changeClass = change !== undefined
|
|
56
|
+
? (isPositiveGood ? (change >= 0 ? 'text-green-600' : 'text-red-600') : (change <= 0 ? 'text-green-600' : 'text-red-600'))
|
|
57
|
+
: ''
|
|
58
|
+
</script>
|
|
59
|
+
|
|
60
|
+
<div class="stat-card card-hover p-6">
|
|
61
|
+
<div class="flex items-center justify-between">
|
|
62
|
+
<h3 class="stat-title text-sm font-medium text-gray-500">
|
|
63
|
+
{{ title }}
|
|
64
|
+
</h3>
|
|
65
|
+
@if (icon)
|
|
66
|
+
<span class="text-gray-400">{{ icon }}</span>
|
|
67
|
+
@endif
|
|
68
|
+
</div>
|
|
69
|
+
|
|
70
|
+
<div class="mt-2">
|
|
71
|
+
@if (loading)
|
|
72
|
+
<div class="animate-pulse">
|
|
73
|
+
<div class="skeleton h-8 w-24 bg-gray-200 rounded"></div>
|
|
74
|
+
<div class="skeleton h-4 w-16 mt-2 bg-gray-200 rounded"></div>
|
|
75
|
+
</div>
|
|
76
|
+
@else
|
|
77
|
+
<p class="stat-value text-2xl font-semibold text-gray-900">
|
|
78
|
+
{{ formattedValue }}
|
|
79
|
+
</p>
|
|
80
|
+
@if (change !== undefined)
|
|
81
|
+
<p class="mt-1 text-sm {{ changeClass }}">
|
|
82
|
+
<span>{{ formattedChange }}</span>
|
|
83
|
+
@if (changeLabel)
|
|
84
|
+
<span class="text-gray-500 ml-1">{{ changeLabel }}</span>
|
|
85
|
+
@endif
|
|
86
|
+
</p>
|
|
87
|
+
@endif
|
|
88
|
+
@endif
|
|
89
|
+
</div>
|
|
90
|
+
</div>
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
{{-- SummaryStats Component --}}
|
|
2
|
+
{{-- Props: stats (object with realtime, people, views, avgTime, bounceRate, events) --}}
|
|
3
|
+
<div class="summary-stats">
|
|
4
|
+
<div class="stat-card">
|
|
5
|
+
<div class="stat-value">{{ stats.realtime }}</div>
|
|
6
|
+
<div class="stat-label">Realtime</div>
|
|
7
|
+
</div>
|
|
8
|
+
<div class="stat-card">
|
|
9
|
+
<div class="stat-value">{{ stats.people | fmt }}</div>
|
|
10
|
+
<div class="stat-label">People</div>
|
|
11
|
+
</div>
|
|
12
|
+
<div class="stat-card">
|
|
13
|
+
<div class="stat-value">{{ stats.views | fmt }}</div>
|
|
14
|
+
<div class="stat-label">Views</div>
|
|
15
|
+
</div>
|
|
16
|
+
<div class="stat-card">
|
|
17
|
+
<div class="stat-value">{{ stats.avgTime }}</div>
|
|
18
|
+
<div class="stat-label">Avg time</div>
|
|
19
|
+
</div>
|
|
20
|
+
<div class="stat-card">
|
|
21
|
+
<div class="stat-value">{{ stats.bounceRate }}%</div>
|
|
22
|
+
<div class="stat-label">Bounce</div>
|
|
23
|
+
</div>
|
|
24
|
+
<div class="stat-card">
|
|
25
|
+
<div class="stat-value">{{ stats.events | fmt }}</div>
|
|
26
|
+
<div class="stat-label">Events</div>
|
|
27
|
+
</div>
|
|
28
|
+
</div>
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
<style>
|
|
32
|
+
.summary-stats {
|
|
33
|
+
display: grid;
|
|
34
|
+
grid-template-columns: repeat(6, 1fr);
|
|
35
|
+
gap: 1rem;
|
|
36
|
+
margin-bottom: 1.5rem;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
.stat-card {
|
|
40
|
+
background: var(--bg-secondary);
|
|
41
|
+
padding: 1.25rem;
|
|
42
|
+
border-radius: 8px;
|
|
43
|
+
text-align: center;
|
|
44
|
+
transition: transform 0.15s, box-shadow 0.15s;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
.stat-card:hover {
|
|
48
|
+
transform: translateY(-2px);
|
|
49
|
+
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
.stat-value {
|
|
53
|
+
font-size: 1.75rem;
|
|
54
|
+
font-weight: 600;
|
|
55
|
+
color: var(--text-primary);
|
|
56
|
+
margin-bottom: 0.25rem;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
.stat-label {
|
|
60
|
+
font-size: 0.75rem;
|
|
61
|
+
color: var(--text-secondary);
|
|
62
|
+
text-transform: uppercase;
|
|
63
|
+
letter-spacing: 0.05em;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
@media (max-width: 1024px) {
|
|
67
|
+
.summary-stats {
|
|
68
|
+
grid-template-columns: repeat(3, 1fr);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
@media (max-width: 640px) {
|
|
73
|
+
.summary-stats {
|
|
74
|
+
grid-template-columns: repeat(2, 1fr);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
.stat-value {
|
|
78
|
+
font-size: 1.5rem;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
</style>
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
{{-- TabNav Component --}}
|
|
2
|
+
{{-- Props: tabs (array of {id, label}), activeTab --}}
|
|
3
|
+
<div class="tabs" data-tab-group="{{ tabGroup || 'default' }}">
|
|
4
|
+
@foreach(tabs as tab)
|
|
5
|
+
<button
|
|
6
|
+
class="tab-btn @if(tab.id === activeTab) active @endif"
|
|
7
|
+
data-tab="{{ tab.id }}"
|
|
8
|
+
type="button"
|
|
9
|
+
>
|
|
10
|
+
{{ tab.label }}
|
|
11
|
+
</button>
|
|
12
|
+
@endforeach
|
|
13
|
+
</div>
|
|
14
|
+
|
|
15
|
+
<script>
|
|
16
|
+
(function() {
|
|
17
|
+
var d = window['document'];
|
|
18
|
+
d['querySelectorAll']('.tabs').forEach(tabContainer => {
|
|
19
|
+
const buttons = tabContainer.querySelectorAll('.tab-btn')
|
|
20
|
+
buttons.forEach(btn => {
|
|
21
|
+
btn.addEventListener('click', () => {
|
|
22
|
+
buttons.forEach(b => b.classList.remove('active'))
|
|
23
|
+
btn.classList.add('active')
|
|
24
|
+
|
|
25
|
+
const tabId = btn.dataset.tab
|
|
26
|
+
const group = tabContainer.dataset.tabGroup
|
|
27
|
+
const event = new CustomEvent('tabchange', {
|
|
28
|
+
detail: { tabId, group },
|
|
29
|
+
bubbles: true
|
|
30
|
+
})
|
|
31
|
+
tabContainer.dispatchEvent(event)
|
|
32
|
+
})
|
|
33
|
+
})
|
|
34
|
+
})
|
|
35
|
+
})();
|
|
36
|
+
</script>
|
|
37
|
+
|
|
38
|
+
<style>
|
|
39
|
+
.tabs {
|
|
40
|
+
display: flex;
|
|
41
|
+
gap: 0.25rem;
|
|
42
|
+
border-bottom: 1px solid var(--border);
|
|
43
|
+
margin-bottom: 1rem;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
.tab-btn {
|
|
47
|
+
background: transparent;
|
|
48
|
+
border: none;
|
|
49
|
+
color: var(--text-secondary);
|
|
50
|
+
padding: 0.5rem 0.75rem;
|
|
51
|
+
font-size: 0.75rem;
|
|
52
|
+
font-weight: 500;
|
|
53
|
+
cursor: pointer;
|
|
54
|
+
border-bottom: 2px solid transparent;
|
|
55
|
+
transition: all 0.15s;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
.tab-btn:hover {
|
|
59
|
+
color: var(--text-primary);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
.tab-btn.active {
|
|
63
|
+
color: var(--accent);
|
|
64
|
+
border-bottom-color: var(--accent);
|
|
65
|
+
}
|
|
66
|
+
</style>
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
<script server>
|
|
2
|
+
/**
|
|
3
|
+
* ThemeSwitcher Component
|
|
4
|
+
*
|
|
5
|
+
* Toggle between light, dark, and system theme modes.
|
|
6
|
+
*/
|
|
7
|
+
import { defineProps, withDefaults } from 'stx'
|
|
8
|
+
|
|
9
|
+
export type ThemeMode = 'light' | 'dark' | 'system'
|
|
10
|
+
|
|
11
|
+
interface ThemeSwitcherProps {
|
|
12
|
+
initialMode?: ThemeMode
|
|
13
|
+
showLabels?: boolean
|
|
14
|
+
persist?: boolean
|
|
15
|
+
storageKey?: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const { initialMode, showLabels, persist, storageKey } = withDefaults(
|
|
19
|
+
defineProps<ThemeSwitcherProps>(),
|
|
20
|
+
{
|
|
21
|
+
initialMode: 'system',
|
|
22
|
+
showLabels: false,
|
|
23
|
+
persist: true,
|
|
24
|
+
storageKey: 'analytics-theme',
|
|
25
|
+
}
|
|
26
|
+
)
|
|
27
|
+
</script>
|
|
28
|
+
|
|
29
|
+
<script client>
|
|
30
|
+
const mode = state('{{ initialMode }}')
|
|
31
|
+
const shouldPersist = {{ persist ? 'true' : 'false' }}
|
|
32
|
+
const themeStorageKey = '{{ storageKey }}'
|
|
33
|
+
|
|
34
|
+
const isDark = derived(() => {
|
|
35
|
+
if (mode() === 'system') {
|
|
36
|
+
return window.matchMedia('(prefers-color-scheme: dark)').matches
|
|
37
|
+
}
|
|
38
|
+
return mode() === 'dark'
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
function updateDocumentClass() {
|
|
42
|
+
if (isDark()) {
|
|
43
|
+
document.documentElement.classList.add('dark')
|
|
44
|
+
} else {
|
|
45
|
+
document.documentElement.classList.remove('dark')
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function setMode(newMode) {
|
|
50
|
+
mode.set(newMode)
|
|
51
|
+
if (shouldPersist) {
|
|
52
|
+
localStorage.setItem(themeStorageKey, newMode)
|
|
53
|
+
}
|
|
54
|
+
updateDocumentClass()
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function cycleMode() {
|
|
58
|
+
const modes = ['light', 'dark', 'system']
|
|
59
|
+
const currentIndex = modes.indexOf(mode())
|
|
60
|
+
const nextIndex = (currentIndex + 1) % modes.length
|
|
61
|
+
setMode(modes[nextIndex])
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
onMount(() => {
|
|
65
|
+
if (shouldPersist) {
|
|
66
|
+
const stored = localStorage.getItem(themeStorageKey)
|
|
67
|
+
if (stored && ['light', 'dark', 'system'].includes(stored)) {
|
|
68
|
+
mode.set(stored)
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
updateDocumentClass()
|
|
72
|
+
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
|
|
73
|
+
if (mode() === 'system') {
|
|
74
|
+
updateDocumentClass()
|
|
75
|
+
}
|
|
76
|
+
})
|
|
77
|
+
})
|
|
78
|
+
</script>
|
|
79
|
+
<div class="theme-switcher">
|
|
80
|
+
@if (!showLabels)
|
|
81
|
+
<button
|
|
82
|
+
type="button"
|
|
83
|
+
class="btn-icon relative"
|
|
84
|
+
:title="'Theme: ' + mode()"
|
|
85
|
+
@click="cycleMode()"
|
|
86
|
+
>
|
|
87
|
+
<Icon name="sun" size="20" :show="mode() === 'light'" />
|
|
88
|
+
<Icon name="moon" size="20" :show="mode() === 'dark'" />
|
|
89
|
+
<Icon name="monitor" size="20" :show="mode() === 'system'" />
|
|
90
|
+
</button>
|
|
91
|
+
@else
|
|
92
|
+
<div class="inline-flex rounded-lg border border-gray-200 p-1 bg-white">
|
|
93
|
+
<button
|
|
94
|
+
type="button"
|
|
95
|
+
class="px-3 py-1.5 text-sm rounded-md transition-colors"
|
|
96
|
+
x-class="mode() === 'light' ? 'bg-gray-100 text-gray-900' : 'text-gray-500 hover:text-gray-700'"
|
|
97
|
+
@click="setMode('light')"
|
|
98
|
+
>
|
|
99
|
+
<Icon name="sun" size="16" class="inline-block mr-1" />
|
|
100
|
+
Light
|
|
101
|
+
</button>
|
|
102
|
+
|
|
103
|
+
<button
|
|
104
|
+
type="button"
|
|
105
|
+
class="px-3 py-1.5 text-sm rounded-md transition-colors"
|
|
106
|
+
x-class="mode() === 'dark' ? 'bg-gray-100 text-gray-900' : 'text-gray-500 hover:text-gray-700'"
|
|
107
|
+
@click="setMode('dark')"
|
|
108
|
+
>
|
|
109
|
+
<Icon name="moon" size="16" class="inline-block mr-1" />
|
|
110
|
+
Dark
|
|
111
|
+
</button>
|
|
112
|
+
|
|
113
|
+
<button
|
|
114
|
+
type="button"
|
|
115
|
+
class="px-3 py-1.5 text-sm rounded-md transition-colors"
|
|
116
|
+
x-class="mode() === 'system' ? 'bg-gray-100 text-gray-900' : 'text-gray-500 hover:text-gray-700'"
|
|
117
|
+
@click="setMode('system')"
|
|
118
|
+
>
|
|
119
|
+
<Icon name="monitor" size="16" class="inline-block mr-1" />
|
|
120
|
+
System
|
|
121
|
+
</button>
|
|
122
|
+
</div>
|
|
123
|
+
@endif
|
|
124
|
+
</div>
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
<script server>
|
|
2
|
+
/**
|
|
3
|
+
* TimeSeriesChart Component
|
|
4
|
+
*
|
|
5
|
+
* Displays time series data as a line chart.
|
|
6
|
+
*/
|
|
7
|
+
import { defineProps, withDefaults } from 'stx'
|
|
8
|
+
import type { TimeSeriesDataPoint } from '../types'
|
|
9
|
+
|
|
10
|
+
interface TimeSeriesChartProps {
|
|
11
|
+
data: TimeSeriesDataPoint[]
|
|
12
|
+
metrics?: string[]
|
|
13
|
+
height?: number
|
|
14
|
+
loading?: boolean
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const { data, metrics, height, loading } = withDefaults(
|
|
18
|
+
defineProps<TimeSeriesChartProps>(),
|
|
19
|
+
{
|
|
20
|
+
metrics: () => ['pageViews', 'uniqueVisitors'],
|
|
21
|
+
height: 300,
|
|
22
|
+
loading: false,
|
|
23
|
+
}
|
|
24
|
+
)
|
|
25
|
+
</script>
|
|
26
|
+
|
|
27
|
+
<div class="chart-container card-hover p-6">
|
|
28
|
+
@if (loading)
|
|
29
|
+
<div class="animate-pulse">
|
|
30
|
+
<div class="skeleton" style="height: {{ height }}px"></div>
|
|
31
|
+
</div>
|
|
32
|
+
@elseif (data.length > 0)
|
|
33
|
+
<div class="chart-header mb-4">
|
|
34
|
+
<div class="chart-legend flex justify-end gap-6 text-xs">
|
|
35
|
+
<span class="legend-item views flex items-center gap-2 text-gray-600">
|
|
36
|
+
<span class="legend-dot w-2.5 h-2.5 rounded-sm bg-indigo-500"></span>
|
|
37
|
+
Views
|
|
38
|
+
</span>
|
|
39
|
+
<span class="legend-item visitors flex items-center gap-2 text-gray-600">
|
|
40
|
+
<span class="legend-dot w-2.5 h-2.5 rounded-sm bg-green-500"></span>
|
|
41
|
+
Visitors
|
|
42
|
+
</span>
|
|
43
|
+
</div>
|
|
44
|
+
</div>
|
|
45
|
+
<script client>
|
|
46
|
+
onMount(() => {
|
|
47
|
+
const canvas = document.getElementById('timeSeriesChart')
|
|
48
|
+
const ctx = canvas?.getContext('2d')
|
|
49
|
+
if (!ctx) return
|
|
50
|
+
|
|
51
|
+
const chartData = {{ JSON.stringify(data) }}
|
|
52
|
+
const labels = chartData.map(d => new Date(d.date).toLocaleDateString())
|
|
53
|
+
|
|
54
|
+
new Chart(ctx, {
|
|
55
|
+
type: 'line',
|
|
56
|
+
data: {
|
|
57
|
+
labels,
|
|
58
|
+
datasets: [
|
|
59
|
+
{
|
|
60
|
+
label: 'Page Views',
|
|
61
|
+
data: chartData.map(d => d.pageViews || d.views || 0),
|
|
62
|
+
borderColor: '#6366f1',
|
|
63
|
+
backgroundColor: '#6366f120',
|
|
64
|
+
fill: true,
|
|
65
|
+
tension: 0.3,
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
label: 'Unique Visitors',
|
|
69
|
+
data: chartData.map(d => d.uniqueVisitors || d.visitors || 0),
|
|
70
|
+
borderColor: '#22c55e',
|
|
71
|
+
backgroundColor: '#22c55e20',
|
|
72
|
+
fill: true,
|
|
73
|
+
tension: 0.3,
|
|
74
|
+
}
|
|
75
|
+
]
|
|
76
|
+
},
|
|
77
|
+
options: {
|
|
78
|
+
responsive: true,
|
|
79
|
+
maintainAspectRatio: false,
|
|
80
|
+
plugins: {
|
|
81
|
+
legend: { display: false }
|
|
82
|
+
},
|
|
83
|
+
scales: {
|
|
84
|
+
y: {
|
|
85
|
+
beginAtZero: true,
|
|
86
|
+
grid: { color: '#e5e7eb' }
|
|
87
|
+
},
|
|
88
|
+
x: {
|
|
89
|
+
grid: { display: false }
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
})
|
|
94
|
+
})
|
|
95
|
+
</script>
|
|
96
|
+
<canvas
|
|
97
|
+
id="timeSeriesChart"
|
|
98
|
+
style="height: {{ height }}px"
|
|
99
|
+
></canvas>
|
|
100
|
+
@else
|
|
101
|
+
<div class="chart-placeholder flex flex-col items-center justify-center text-gray-400 gap-4" style="height: {{ height }}px">
|
|
102
|
+
<Icon name="bar-chart-2" size="48" />
|
|
103
|
+
<p>No data available for selected period</p>
|
|
104
|
+
</div>
|
|
105
|
+
@endif
|
|
106
|
+
</div>
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/vue3'
|
|
2
|
+
import TopList from './TopList.stx'
|
|
3
|
+
|
|
4
|
+
const meta = {
|
|
5
|
+
title: 'Components/Lists/TopList',
|
|
6
|
+
component: TopList,
|
|
7
|
+
tags: ['autodocs'],
|
|
8
|
+
argTypes: {
|
|
9
|
+
title: { control: 'text' },
|
|
10
|
+
valueLabel: { control: 'text' },
|
|
11
|
+
maxItems: { control: { type: 'number', min: 1, max: 20 } },
|
|
12
|
+
loading: { control: 'boolean' },
|
|
13
|
+
},
|
|
14
|
+
} satisfies Meta<typeof TopList>
|
|
15
|
+
|
|
16
|
+
export default meta
|
|
17
|
+
type Story = StoryObj<typeof meta>
|
|
18
|
+
|
|
19
|
+
const sampleItems = [
|
|
20
|
+
{ name: '/home', value: 12847, change: 0.12 },
|
|
21
|
+
{ name: '/products', value: 8923, change: 0.08 },
|
|
22
|
+
{ name: '/about', value: 5634, change: 0.24 },
|
|
23
|
+
{ name: '/blog', value: 4521, change: -0.05 },
|
|
24
|
+
{ name: '/contact', value: 2134, change: 0.03 },
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
export const Default: Story = {
|
|
28
|
+
args: {
|
|
29
|
+
items: sampleItems,
|
|
30
|
+
title: 'Top Pages',
|
|
31
|
+
valueLabel: 'Views',
|
|
32
|
+
},
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export const WithoutChanges: Story = {
|
|
36
|
+
args: {
|
|
37
|
+
items: sampleItems.map(({ name, value }) => ({ name, value })),
|
|
38
|
+
title: 'Top Referrers',
|
|
39
|
+
valueLabel: 'Visitors',
|
|
40
|
+
},
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export const Loading: Story = {
|
|
44
|
+
args: {
|
|
45
|
+
items: [],
|
|
46
|
+
title: 'Top Pages',
|
|
47
|
+
loading: true,
|
|
48
|
+
},
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export const LimitedItems: Story = {
|
|
52
|
+
args: {
|
|
53
|
+
items: sampleItems,
|
|
54
|
+
title: 'Top 3 Pages',
|
|
55
|
+
valueLabel: 'Views',
|
|
56
|
+
maxItems: 3,
|
|
57
|
+
},
|
|
58
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
<script server>
|
|
2
|
+
/**
|
|
3
|
+
* TopList Component
|
|
4
|
+
*
|
|
5
|
+
* Displays a list of top items (pages, referrers, etc.) with bars.
|
|
6
|
+
*/
|
|
7
|
+
import { defineProps, withDefaults } from 'stx'
|
|
8
|
+
import type { TopListProps } from '../types'
|
|
9
|
+
import { formatCompact, formatPercentage } from '../utils'
|
|
10
|
+
|
|
11
|
+
const { title, items, showPercentage, maxItems, emptyMessage, loading } = withDefaults(
|
|
12
|
+
defineProps<TopListProps>(),
|
|
13
|
+
{
|
|
14
|
+
showPercentage: true,
|
|
15
|
+
maxItems: 10,
|
|
16
|
+
emptyMessage: 'No data available',
|
|
17
|
+
loading: false,
|
|
18
|
+
}
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
const displayItems = items.slice(0, maxItems)
|
|
22
|
+
const maxValue = Math.max(...items.map(item => item.value), 1)
|
|
23
|
+
</script>
|
|
24
|
+
|
|
25
|
+
<div class="top-list card-hover p-6">
|
|
26
|
+
<h3 class="text-sm font-medium text-gray-900 mb-4">
|
|
27
|
+
{{ title }}
|
|
28
|
+
</h3>
|
|
29
|
+
|
|
30
|
+
@if (loading)
|
|
31
|
+
<div class="space-y-3">
|
|
32
|
+
@foreach ([1, 2, 3, 4, 5] as i)
|
|
33
|
+
<div class="animate-pulse">
|
|
34
|
+
<div class="flex justify-between mb-1">
|
|
35
|
+
<div class="skeleton h-4 w-32"></div>
|
|
36
|
+
<div class="skeleton h-4 w-12"></div>
|
|
37
|
+
</div>
|
|
38
|
+
<div class="skeleton h-2"></div>
|
|
39
|
+
</div>
|
|
40
|
+
@endforeach
|
|
41
|
+
</div>
|
|
42
|
+
@elseif (items.length === 0)
|
|
43
|
+
<div class="text-center py-8 text-gray-500">
|
|
44
|
+
{{ emptyMessage }}
|
|
45
|
+
</div>
|
|
46
|
+
@else
|
|
47
|
+
<div class="space-y-3">
|
|
48
|
+
@foreach (displayItems as item)
|
|
49
|
+
<div class="group">
|
|
50
|
+
<div class="list-item mb-1">
|
|
51
|
+
<span class="list-label" title="{{ item.name }}">
|
|
52
|
+
{{ item.name }}
|
|
53
|
+
</span>
|
|
54
|
+
<span class="list-value">
|
|
55
|
+
{{ formatCompact(item.value) }}
|
|
56
|
+
@if (showPercentage)
|
|
57
|
+
<span class="text-gray-400 ml-1">
|
|
58
|
+
({{ formatPercentage(item.percentage) }})
|
|
59
|
+
</span>
|
|
60
|
+
@endif
|
|
61
|
+
</span>
|
|
62
|
+
</div>
|
|
63
|
+
|
|
64
|
+
<div class="progress-bar">
|
|
65
|
+
<div
|
|
66
|
+
class="progress-fill bg-indigo-500"
|
|
67
|
+
style="width: {{ (item.value / maxValue) * 100 }}%"
|
|
68
|
+
></div>
|
|
69
|
+
</div>
|
|
70
|
+
</div>
|
|
71
|
+
@endforeach
|
|
72
|
+
</div>
|
|
73
|
+
@endif
|
|
74
|
+
</div>
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
<script server>
|
|
2
|
+
/**
|
|
3
|
+
* TrendIndicator Component
|
|
4
|
+
*
|
|
5
|
+
* Compact trend indicator showing direction and percentage change.
|
|
6
|
+
*/
|
|
7
|
+
import { defineProps, withDefaults } from 'stx'
|
|
8
|
+
|
|
9
|
+
interface TrendIndicatorProps {
|
|
10
|
+
value: number // Percentage change (-100 to +100+)
|
|
11
|
+
size?: 'sm' | 'md' | 'lg'
|
|
12
|
+
showValue?: boolean
|
|
13
|
+
invertColors?: boolean // For metrics where decrease is good
|
|
14
|
+
neutral?: number // Threshold for neutral (default 0)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const { value, size, showValue, invertColors, neutral } = withDefaults(
|
|
18
|
+
defineProps<TrendIndicatorProps>(),
|
|
19
|
+
{
|
|
20
|
+
size: 'md',
|
|
21
|
+
showValue: true,
|
|
22
|
+
invertColors: false,
|
|
23
|
+
neutral: 0,
|
|
24
|
+
}
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
const isPositive = value > neutral
|
|
28
|
+
const isNegative = value < neutral
|
|
29
|
+
const isNeutral = value === neutral
|
|
30
|
+
|
|
31
|
+
function getColorClass(): string {
|
|
32
|
+
if (isNeutral)
|
|
33
|
+
return 'text-gray-500 bg-gray-100'
|
|
34
|
+
|
|
35
|
+
const positive = isPositive
|
|
36
|
+
const goodTrend = invertColors ? !positive : positive
|
|
37
|
+
|
|
38
|
+
return goodTrend
|
|
39
|
+
? 'text-green-600 bg-green-100'
|
|
40
|
+
: 'text-red-600 bg-red-100'
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function getSizeClasses(): { wrapper: string, icon: string } {
|
|
44
|
+
switch (size) {
|
|
45
|
+
case 'sm':
|
|
46
|
+
return { wrapper: 'text-xs px-1.5 py-0.5 gap-0.5', icon: 'w-3 h-3' }
|
|
47
|
+
case 'lg':
|
|
48
|
+
return { wrapper: 'text-base px-3 py-1.5 gap-1.5', icon: 'w-5 h-5' }
|
|
49
|
+
default:
|
|
50
|
+
return { wrapper: 'text-sm px-2 py-1 gap-1', icon: 'w-4 h-4' }
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function formatValue(): string {
|
|
55
|
+
const abs = Math.abs(value)
|
|
56
|
+
if (abs >= 1000)
|
|
57
|
+
return `${(abs / 1000).toFixed(1)}k`
|
|
58
|
+
if (abs >= 100)
|
|
59
|
+
return abs.toFixed(0)
|
|
60
|
+
if (abs >= 10)
|
|
61
|
+
return abs.toFixed(1)
|
|
62
|
+
return abs.toFixed(2)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const colorClass = getColorClass()
|
|
66
|
+
const sizeClasses = getSizeClasses()
|
|
67
|
+
const formattedValue = formatValue()
|
|
68
|
+
</script>
|
|
69
|
+
|
|
70
|
+
<span class="inline-flex items-center rounded-full font-medium {{ colorClass }} {{ sizeClasses.wrapper }}">
|
|
71
|
+
@if (isPositive)
|
|
72
|
+
<Icon name="arrow-up" class="{{ sizeClasses.icon }}" />
|
|
73
|
+
@elseif (isNegative)
|
|
74
|
+
<Icon name="arrow-down" class="{{ sizeClasses.icon }}" />
|
|
75
|
+
@else
|
|
76
|
+
<Icon name="minus" class="{{ sizeClasses.icon }}" />
|
|
77
|
+
@endif
|
|
78
|
+
|
|
79
|
+
@if (showValue)
|
|
80
|
+
<span>
|
|
81
|
+
{{ isPositive ? '+' : '' }}{{ formattedValue }}%
|
|
82
|
+
</span>
|
|
83
|
+
@endif
|
|
84
|
+
</span>
|