@payez/next-mvp 4.1.9 → 4.1.10
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/README.md +792 -792
- package/dist/api-handlers/auth/refresh.js +38 -15
- package/dist/auth/better-auth.js +7 -18
- package/dist/components/account/MobileNavDrawer.js +18 -18
- package/dist/components/account/UserAvatarMenu.js +1 -1
- package/dist/components/admin/AlertSettingsTab.js +40 -40
- package/dist/components/admin/LoggingSettingsTab.js +28 -28
- package/dist/lib/ensure-fresh-access-token.js +30 -7
- package/dist/lib/idp-fetch.js +25 -9
- package/dist/lib/session-store.d.ts +1 -0
- package/dist/lib/session-store.js +12 -8
- package/dist/pages/login/page.js +28 -28
- package/dist/pages/settings/EnhancedSettingsPage.js +17 -17
- package/package.json +893 -893
- package/scripts/dev-broker.ps1 +35 -35
- package/scripts/dev-local.ps1 +45 -45
- package/src/api/auth-handler.ts +548 -548
- package/src/api-handlers/account/change-password.ts +142 -142
- package/src/api-handlers/account/update-phone.ts +79 -79
- package/src/api-handlers/account/verify-email.ts +125 -125
- package/src/api-handlers/account/verify-sms.ts +125 -125
- package/src/api-handlers/admin/analytics.ts +443 -443
- package/src/api-handlers/admin/audit.ts +223 -223
- package/src/api-handlers/admin/index.ts +63 -63
- package/src/api-handlers/admin/redis-sessions.ts +251 -251
- package/src/api-handlers/admin/sessions.ts +318 -318
- package/src/api-handlers/admin/site-logs.ts +365 -365
- package/src/api-handlers/admin/users.ts +242 -242
- package/src/api-handlers/admin/vibe-data.ts +324 -324
- package/src/api-handlers/auth/refresh.ts +705 -682
- package/src/api-handlers/auth/signout.ts +199 -199
- package/src/api-handlers/auth/status.ts +20 -20
- package/src/api-handlers/auth/update-session.ts +110 -110
- package/src/api-handlers/auth/verify-code.ts +112 -112
- package/src/api-handlers/session/refresh-viability.ts +36 -36
- package/src/api-handlers/test/force-expire.ts +60 -60
- package/src/auth/auth-decision.ts +229 -229
- package/src/auth/better-auth.ts +7 -18
- package/src/auth/route-config.ts +219 -219
- package/src/auth/types/auth-types.ts +555 -555
- package/src/auth/types/index.ts +7 -7
- package/src/auth/utils/idp-client.ts +445 -445
- package/src/auth/utils/index.ts +6 -6
- package/src/auth/utils/token-utils.ts +243 -243
- package/src/client/AuthContext.tsx +143 -143
- package/src/client/fetch-with-auth.ts +47 -47
- package/src/components/SessionSync.tsx +138 -138
- package/src/components/SignalRHealthCheck.tsx +131 -131
- package/src/components/account/MobileNavDrawer.tsx +305 -305
- package/src/components/account/UserAvatarMenu.tsx +266 -266
- package/src/components/account/index.ts +13 -13
- package/src/components/admin/AlertSettingsTab.tsx +728 -728
- package/src/components/admin/AnalyticsTab.tsx +703 -703
- package/src/components/admin/DataBrowserTab.tsx +505 -505
- package/src/components/admin/LoggingSettingsTab.tsx +665 -665
- package/src/components/admin/SessionsTab.tsx +414 -414
- package/src/components/admin/StatsTab.tsx +379 -379
- package/src/components/admin/VibeAdminLayout.tsx +187 -187
- package/src/components/admin/index.ts +59 -59
- package/src/components/auth/FederatedAuthSection.tsx +95 -95
- package/src/components/auth/ModeAwareLoginPage.tsx +135 -135
- package/src/components/auth/ModeAwareSignupPage.tsx +267 -267
- package/src/components/auth/TraditionalAuthSection.tsx +99 -99
- package/src/components/recovery/CompleteStep.tsx +36 -36
- package/src/components/recovery/InitiateRecoveryStep.tsx +68 -68
- package/src/components/recovery/SelectMethodStep.tsx +73 -73
- package/src/components/recovery/SetPasswordStep.tsx +97 -97
- package/src/components/recovery/VerifyCodeStep.tsx +90 -90
- package/src/components/reserved/ReservedRecoveryWarning.tsx +160 -160
- package/src/components/reserved/ReservedStatusBox.tsx +118 -118
- package/src/components/ui/BetaBadge.tsx +58 -58
- package/src/components/ui/Footer.tsx +93 -93
- package/src/config/env.ts +57 -57
- package/src/config/logger.ts +62 -62
- package/src/config/vibe-log-transport.ts +264 -264
- package/src/hooks/useAuth.ts +117 -117
- package/src/hooks/useAuthSettings.ts +97 -97
- package/src/hooks/useAvailableProviders.ts +120 -120
- package/src/hooks/usePasswordValidation.ts +127 -127
- package/src/hooks/usePublicAuthSettings.ts +149 -149
- package/src/hooks/useSessionExpiration.ts +101 -101
- package/src/hooks/useViabilitySession.ts +336 -336
- package/src/index.ts +60 -60
- package/src/lib/api-handler.ts +643 -643
- package/src/lib/app-slug.ts +178 -178
- package/src/lib/ensure-fresh-access-token.ts +358 -335
- package/src/lib/geolocation.ts +265 -265
- package/src/lib/idp-fetch.ts +26 -10
- package/src/lib/jwt-decode.ts +82 -82
- package/src/lib/roles.ts +177 -177
- package/src/lib/session-store.ts +4 -1
- package/src/lib/site-logger.ts +245 -245
- package/src/lib/standardized-client-api.ts +901 -901
- package/src/lib/test-aware-get-token.ts +41 -41
- package/src/lib/user-agent-parser.ts +252 -252
- package/src/logging/api/admin-analytics.ts +51 -51
- package/src/logging/api/audit-log.ts +53 -53
- package/src/logging/components/AdminAnalyticsLayout.tsx +49 -49
- package/src/logging/components/AuditLogViewer.tsx +125 -125
- package/src/logging/components/ErrorMetricsCard.tsx +98 -98
- package/src/logging/components/HealthMetricsCard.tsx +70 -70
- package/src/logging/hooks/useAdminAnalytics.ts +22 -22
- package/src/logging/hooks/useAuditLog.ts +24 -24
- package/src/logging/hooks/useErrorMetrics.ts +40 -40
- package/src/logging/hooks/useHealthMetrics.ts +44 -44
- package/src/logging/index.ts +18 -18
- package/src/logging/types/analytics.ts +81 -81
- package/src/logging/types/audit.ts +31 -31
- package/src/logging/types/index.ts +3 -3
- package/src/middleware/create-middleware.ts +663 -663
- package/src/middleware/rbac-check.ts +279 -279
- package/src/middleware/twofa-presets.ts +224 -224
- package/src/pages/admin-login/page.tsx +221 -221
- package/src/pages/admin-page-permissions/PagePermissionsAdminPage.tsx +527 -527
- package/src/pages/admin-page-permissions/index.ts +7 -7
- package/src/pages/admin-roles/RolesAdminPage.tsx +533 -533
- package/src/pages/admin-roles/modals.tsx +469 -469
- package/src/pages/client-admin/ClientSiteAdminPage.tsx +382 -382
- package/src/pages/client-admin/index.ts +33 -33
- package/src/pages/coming-soon/page.tsx +83 -83
- package/src/pages/login/page.tsx +456 -456
- package/src/pages/profile/EnhancedProfilePage.tsx +485 -485
- package/src/pages/profile/index.ts +9 -9
- package/src/pages/profile/page.tsx +166 -166
- package/src/pages/recovery/page.tsx +234 -234
- package/src/pages/roles/MyRolesPage.tsx +211 -211
- package/src/pages/roles/components.tsx +294 -294
- package/src/pages/roles/index.ts +17 -17
- package/src/pages/security/EnhancedSecurityPage.tsx +565 -565
- package/src/pages/security/index.ts +9 -9
- package/src/pages/security/page.tsx +507 -507
- package/src/pages/settings/EnhancedSettingsPage.tsx +642 -642
- package/src/pages/settings/index.ts +9 -9
- package/src/pages/settings/page.tsx +47 -47
- package/src/pages/showcase/ShowcasePage.tsx +532 -532
- package/src/pages/showcase/index.ts +13 -13
- package/src/pages/test-env/EmergencyLogoutPage.tsx +180 -180
- package/src/pages/test-env/JwtInspectPage.tsx +456 -456
- package/src/pages/test-env/RefreshTokenPage.tsx +157 -157
- package/src/pages/test-env/TestEnvPage.tsx +118 -118
- package/src/pages/test-env/index.ts +25 -25
- package/src/pages/verify-code/page.tsx +652 -652
- package/src/routes/account/update-phone.ts +13 -13
- package/src/routes/auth/logout.ts +108 -108
- package/src/routes/auth/nextauth.ts +45 -45
- package/src/routes/auth/settings.ts +75 -75
- package/src/routes/auth/viability.ts +224 -224
- package/src/server/auth-guard.ts +170 -170
- package/src/server/slim-middleware.ts +117 -117
- package/src/server/with-auth.ts +93 -93
- package/src/stores/authStore.ts +1899 -1899
- package/src/templates/instrumentation.ts +41 -41
- package/src/utils/logout.ts +30 -30
- package/src/vibe/client.ts +590 -590
- package/src/vibe/enterprise-auth.ts +208 -208
- package/src/vibe/errors.ts +185 -185
- package/src/vibe/generic.ts +431 -431
- package/src/vibe/hooks/index.ts +367 -367
- package/src/vibe/index.ts +132 -132
- package/src/vibe/sessions.ts +551 -551
- package/src/vibe/types.ts +577 -577
|
@@ -1,703 +1,703 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* =============================================================================
|
|
3
|
-
* VIBE ADMIN ANALYTICS TAB
|
|
4
|
-
* =============================================================================
|
|
5
|
-
*
|
|
6
|
-
* Generic dashboard tab showing login statistics and analytics.
|
|
7
|
-
* Can be extended by consumer apps with custom components.
|
|
8
|
-
*
|
|
9
|
-
* =============================================================================
|
|
10
|
-
*/
|
|
11
|
-
|
|
12
|
-
'use client';
|
|
13
|
-
|
|
14
|
-
import React, { useState, useEffect, useCallback, ReactNode } from 'react';
|
|
15
|
-
|
|
16
|
-
// -----------------------------------------------------------------------------
|
|
17
|
-
// TYPES
|
|
18
|
-
// -----------------------------------------------------------------------------
|
|
19
|
-
|
|
20
|
-
type Period = 'today' | 'week' | 'month' | 'year';
|
|
21
|
-
|
|
22
|
-
interface TimeSeries {
|
|
23
|
-
date: string;
|
|
24
|
-
count: number;
|
|
25
|
-
label: string;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
interface CountryStats {
|
|
29
|
-
country: string;
|
|
30
|
-
code: string;
|
|
31
|
-
flag: string;
|
|
32
|
-
count: number;
|
|
33
|
-
percentage: number;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
interface UserStats {
|
|
37
|
-
email: string;
|
|
38
|
-
name: string;
|
|
39
|
-
count: number;
|
|
40
|
-
lastLogin: string;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
interface TierDistribution {
|
|
44
|
-
tier: string;
|
|
45
|
-
count: number;
|
|
46
|
-
percentage: number;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
interface FeatureUsage {
|
|
50
|
-
name: string;
|
|
51
|
-
uses: number;
|
|
52
|
-
uniqueUsers: number;
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
interface RevenueByTier {
|
|
56
|
-
tier: string;
|
|
57
|
-
subscribers: number;
|
|
58
|
-
pricePerMonth: number;
|
|
59
|
-
mrr: number;
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
interface RevenueTrend {
|
|
63
|
-
month: string;
|
|
64
|
-
mrr: number;
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
interface TierData {
|
|
68
|
-
distribution: TierDistribution[];
|
|
69
|
-
total: number;
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
interface FeatureData {
|
|
73
|
-
features: FeatureUsage[];
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
interface RevenueData {
|
|
77
|
-
mrr: number;
|
|
78
|
-
arr: number;
|
|
79
|
-
paidSubscribers: number;
|
|
80
|
-
totalUsers: number;
|
|
81
|
-
conversionRate: number;
|
|
82
|
-
revenueByTier: RevenueByTier[];
|
|
83
|
-
trend: RevenueTrend[];
|
|
84
|
-
currency: string;
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
interface AnalyticsData {
|
|
88
|
-
period: Period;
|
|
89
|
-
dateRange: {
|
|
90
|
-
start: string;
|
|
91
|
-
end: string;
|
|
92
|
-
};
|
|
93
|
-
summary: {
|
|
94
|
-
totalLogins: number;
|
|
95
|
-
uniqueUsers: number;
|
|
96
|
-
newUsers: number;
|
|
97
|
-
peakHour: string;
|
|
98
|
-
avgLoginsPerDay: number;
|
|
99
|
-
};
|
|
100
|
-
timeSeries: TimeSeries[];
|
|
101
|
-
byCountry: CountryStats[];
|
|
102
|
-
byDevice: Record<string, number>;
|
|
103
|
-
byBrowser: Record<string, number>;
|
|
104
|
-
byOS: Record<string, number>;
|
|
105
|
-
topUsers: UserStats[];
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
export interface AnalyticsTabProps {
|
|
109
|
-
isDark?: boolean;
|
|
110
|
-
/** Base API path for analytics (default: /api/admin/analytics) */
|
|
111
|
-
apiBasePath?: string;
|
|
112
|
-
/** Optional custom component to render in the geo section (e.g., WorldMap) */
|
|
113
|
-
geoMapComponent?: ReactNode;
|
|
114
|
-
/** Show business metrics section (default: true) */
|
|
115
|
-
showBusinessMetrics?: boolean;
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
// -----------------------------------------------------------------------------
|
|
119
|
-
// COMPONENT
|
|
120
|
-
// -----------------------------------------------------------------------------
|
|
121
|
-
|
|
122
|
-
export function AnalyticsTab({
|
|
123
|
-
isDark = true,
|
|
124
|
-
apiBasePath = '/api/admin/analytics',
|
|
125
|
-
geoMapComponent,
|
|
126
|
-
showBusinessMetrics = true,
|
|
127
|
-
}: AnalyticsTabProps) {
|
|
128
|
-
const [period, setPeriod] = useState<Period>('week');
|
|
129
|
-
const [data, setData] = useState<AnalyticsData | null>(null);
|
|
130
|
-
const [loading, setLoading] = useState(true);
|
|
131
|
-
const [error, setError] = useState<string | null>(null);
|
|
132
|
-
|
|
133
|
-
// Business metrics data
|
|
134
|
-
const [tierData, setTierData] = useState<TierData | null>(null);
|
|
135
|
-
const [featureData, setFeatureData] = useState<FeatureData | null>(null);
|
|
136
|
-
const [revenueData, setRevenueData] = useState<RevenueData | null>(null);
|
|
137
|
-
|
|
138
|
-
const themeClasses = {
|
|
139
|
-
cardBg: isDark ? 'bg-slate-800 border-slate-700' : 'bg-white border-gray-200 shadow-sm',
|
|
140
|
-
textPrimary: isDark ? 'text-white' : 'text-gray-900',
|
|
141
|
-
textSecondary: isDark ? 'text-gray-400' : 'text-gray-600',
|
|
142
|
-
textMuted: isDark ? 'text-gray-500' : 'text-gray-500',
|
|
143
|
-
barBg: isDark ? 'bg-slate-700' : 'bg-gray-200',
|
|
144
|
-
};
|
|
145
|
-
|
|
146
|
-
const fetchAnalytics = useCallback(async () => {
|
|
147
|
-
setLoading(true);
|
|
148
|
-
setError(null);
|
|
149
|
-
try {
|
|
150
|
-
const res = await fetch(`${apiBasePath}/logins?period=${period}`);
|
|
151
|
-
if (!res.ok) throw new Error('Failed to fetch analytics');
|
|
152
|
-
const json = await res.json();
|
|
153
|
-
setData(json);
|
|
154
|
-
} catch (err: any) {
|
|
155
|
-
setError(err.message);
|
|
156
|
-
} finally {
|
|
157
|
-
setLoading(false);
|
|
158
|
-
}
|
|
159
|
-
}, [period, apiBasePath]);
|
|
160
|
-
|
|
161
|
-
// Fetch tier, feature, and revenue data
|
|
162
|
-
const fetchBusinessMetrics = useCallback(async () => {
|
|
163
|
-
if (!showBusinessMetrics) return;
|
|
164
|
-
|
|
165
|
-
try {
|
|
166
|
-
const [tierRes, featureRes, revenueRes] = await Promise.all([
|
|
167
|
-
fetch(`${apiBasePath}/tiers`),
|
|
168
|
-
fetch(`${apiBasePath}/feature-usage`),
|
|
169
|
-
fetch(`${apiBasePath}/revenue`),
|
|
170
|
-
]);
|
|
171
|
-
|
|
172
|
-
if (tierRes.ok) {
|
|
173
|
-
const data = await tierRes.json();
|
|
174
|
-
setTierData(data);
|
|
175
|
-
}
|
|
176
|
-
if (featureRes.ok) {
|
|
177
|
-
const data = await featureRes.json();
|
|
178
|
-
setFeatureData(data);
|
|
179
|
-
}
|
|
180
|
-
if (revenueRes.ok) {
|
|
181
|
-
const data = await revenueRes.json();
|
|
182
|
-
setRevenueData(data);
|
|
183
|
-
}
|
|
184
|
-
} catch (err) {
|
|
185
|
-
console.error('Failed to fetch business metrics:', err);
|
|
186
|
-
}
|
|
187
|
-
}, [apiBasePath, showBusinessMetrics]);
|
|
188
|
-
|
|
189
|
-
useEffect(() => {
|
|
190
|
-
fetchAnalytics();
|
|
191
|
-
fetchBusinessMetrics();
|
|
192
|
-
}, [fetchAnalytics, fetchBusinessMetrics]);
|
|
193
|
-
|
|
194
|
-
const formatDate = (dateString: string) => {
|
|
195
|
-
return new Date(dateString).toLocaleDateString('en-US', {
|
|
196
|
-
month: 'short',
|
|
197
|
-
day: 'numeric',
|
|
198
|
-
hour: '2-digit',
|
|
199
|
-
minute: '2-digit',
|
|
200
|
-
});
|
|
201
|
-
};
|
|
202
|
-
|
|
203
|
-
// Simple bar chart component
|
|
204
|
-
const BarChart = ({
|
|
205
|
-
data,
|
|
206
|
-
maxValue,
|
|
207
|
-
colorClass = 'bg-blue-500'
|
|
208
|
-
}: {
|
|
209
|
-
data: { label: string; value: number }[];
|
|
210
|
-
maxValue: number;
|
|
211
|
-
colorClass?: string;
|
|
212
|
-
}) => (
|
|
213
|
-
<div className="space-y-2">
|
|
214
|
-
{data.map((item, i) => (
|
|
215
|
-
<div key={i} className="flex items-center gap-2">
|
|
216
|
-
<span className={`text-xs ${themeClasses.textSecondary} w-20 truncate`}>{item.label}</span>
|
|
217
|
-
<div className={`flex-1 h-6 ${themeClasses.barBg} rounded overflow-hidden`}>
|
|
218
|
-
<div
|
|
219
|
-
className={`h-full ${colorClass} transition-all duration-300`}
|
|
220
|
-
style={{ width: `${maxValue > 0 ? (item.value / maxValue) * 100 : 0}%` }}
|
|
221
|
-
/>
|
|
222
|
-
</div>
|
|
223
|
-
<span className={`text-xs ${themeClasses.textPrimary} w-12 text-right`}>{item.value}</span>
|
|
224
|
-
</div>
|
|
225
|
-
))}
|
|
226
|
-
</div>
|
|
227
|
-
);
|
|
228
|
-
|
|
229
|
-
// Time series line chart (simplified as bar chart)
|
|
230
|
-
const TimeSeriesChart = ({ series }: { series: TimeSeries[] }) => {
|
|
231
|
-
const maxCount = Math.max(...series.map(s => s.count), 1);
|
|
232
|
-
return (
|
|
233
|
-
<div className="h-48 flex items-end gap-1">
|
|
234
|
-
{series.map((item, i) => (
|
|
235
|
-
<div
|
|
236
|
-
key={i}
|
|
237
|
-
className="flex-1 flex flex-col items-center group relative"
|
|
238
|
-
>
|
|
239
|
-
<div
|
|
240
|
-
className="w-full bg-blue-500 rounded-t transition-all duration-300 hover:bg-blue-400"
|
|
241
|
-
style={{ height: `${(item.count / maxCount) * 100}%`, minHeight: item.count > 0 ? '4px' : '0' }}
|
|
242
|
-
/>
|
|
243
|
-
{/* Tooltip */}
|
|
244
|
-
<div className="absolute bottom-full mb-2 hidden group-hover:block z-10">
|
|
245
|
-
<div className={`${isDark ? 'bg-slate-900' : 'bg-gray-800'} text-white text-xs px-2 py-1 rounded shadow-lg whitespace-nowrap`}>
|
|
246
|
-
{item.label}: {item.count}
|
|
247
|
-
</div>
|
|
248
|
-
</div>
|
|
249
|
-
</div>
|
|
250
|
-
))}
|
|
251
|
-
</div>
|
|
252
|
-
);
|
|
253
|
-
};
|
|
254
|
-
|
|
255
|
-
if (loading && !data) {
|
|
256
|
-
return (
|
|
257
|
-
<div className="flex items-center justify-center py-12">
|
|
258
|
-
<svg className="animate-spin h-8 w-8 text-blue-400" viewBox="0 0 24 24" fill="none">
|
|
259
|
-
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
|
260
|
-
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" />
|
|
261
|
-
</svg>
|
|
262
|
-
</div>
|
|
263
|
-
);
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
if (error) {
|
|
267
|
-
return (
|
|
268
|
-
<div className={`${isDark ? 'bg-red-900/30 border-red-700' : 'bg-red-50 border-red-300'} border rounded-lg p-4 ${isDark ? 'text-red-300' : 'text-red-700'}`}>
|
|
269
|
-
{error}
|
|
270
|
-
<button onClick={fetchAnalytics} className="ml-4 underline">Retry</button>
|
|
271
|
-
</div>
|
|
272
|
-
);
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
if (!data) return null;
|
|
276
|
-
|
|
277
|
-
const deviceData = Object.entries(data.byDevice)
|
|
278
|
-
.map(([label, value]) => ({ label: label.charAt(0).toUpperCase() + label.slice(1), value }))
|
|
279
|
-
.sort((a, b) => b.value - a.value);
|
|
280
|
-
|
|
281
|
-
const browserData = Object.entries(data.byBrowser)
|
|
282
|
-
.map(([label, value]) => ({ label, value }))
|
|
283
|
-
.sort((a, b) => b.value - a.value)
|
|
284
|
-
.slice(0, 5);
|
|
285
|
-
|
|
286
|
-
const osData = Object.entries(data.byOS)
|
|
287
|
-
.map(([label, value]) => ({ label, value }))
|
|
288
|
-
.sort((a, b) => b.value - a.value)
|
|
289
|
-
.slice(0, 5);
|
|
290
|
-
|
|
291
|
-
return (
|
|
292
|
-
<div className="space-y-6">
|
|
293
|
-
{/* Period Selector */}
|
|
294
|
-
<div className="flex items-center justify-between">
|
|
295
|
-
<div className="flex gap-2">
|
|
296
|
-
{(['today', 'week', 'month', 'year'] as Period[]).map((p) => (
|
|
297
|
-
<button
|
|
298
|
-
key={p}
|
|
299
|
-
onClick={() => setPeriod(p)}
|
|
300
|
-
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
|
301
|
-
period === p
|
|
302
|
-
? 'bg-blue-600 text-white'
|
|
303
|
-
: isDark ? 'bg-slate-700 text-gray-300 hover:bg-slate-600' : 'bg-gray-200 text-gray-700 hover:bg-gray-300'
|
|
304
|
-
}`}
|
|
305
|
-
>
|
|
306
|
-
{p === 'today' ? 'Today' : p === 'week' ? 'This Week' : p === 'month' ? 'This Month' : 'This Year'}
|
|
307
|
-
</button>
|
|
308
|
-
))}
|
|
309
|
-
</div>
|
|
310
|
-
<button
|
|
311
|
-
onClick={fetchAnalytics}
|
|
312
|
-
disabled={loading}
|
|
313
|
-
className={`p-2 ${themeClasses.textSecondary} hover:${themeClasses.textPrimary} transition-colors disabled:opacity-50`}
|
|
314
|
-
title="Refresh"
|
|
315
|
-
>
|
|
316
|
-
<svg className={`w-5 h-5 ${loading ? 'animate-spin' : ''}`} fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
317
|
-
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
|
318
|
-
</svg>
|
|
319
|
-
</button>
|
|
320
|
-
</div>
|
|
321
|
-
|
|
322
|
-
{/* Summary Cards */}
|
|
323
|
-
<div className="grid grid-cols-2 md:grid-cols-5 gap-4">
|
|
324
|
-
<div className={`${themeClasses.cardBg} border rounded-lg p-4`}>
|
|
325
|
-
<p className={themeClasses.textSecondary + ' text-sm'}>Total Logins</p>
|
|
326
|
-
<p className={`text-3xl font-bold ${themeClasses.textPrimary}`}>{data.summary.totalLogins.toLocaleString()}</p>
|
|
327
|
-
</div>
|
|
328
|
-
<div className={`${themeClasses.cardBg} border rounded-lg p-4`}>
|
|
329
|
-
<p className={themeClasses.textSecondary + ' text-sm'}>Unique Users</p>
|
|
330
|
-
<p className="text-3xl font-bold text-blue-400">{data.summary.uniqueUsers.toLocaleString()}</p>
|
|
331
|
-
</div>
|
|
332
|
-
<div className={`${themeClasses.cardBg} border rounded-lg p-4`}>
|
|
333
|
-
<p className={themeClasses.textSecondary + ' text-sm'}>New Users</p>
|
|
334
|
-
<p className="text-3xl font-bold text-green-400">{data.summary.newUsers.toLocaleString()}</p>
|
|
335
|
-
</div>
|
|
336
|
-
<div className={`${themeClasses.cardBg} border rounded-lg p-4`}>
|
|
337
|
-
<p className={themeClasses.textSecondary + ' text-sm'}>Peak Hour</p>
|
|
338
|
-
<p className="text-3xl font-bold text-purple-400">{data.summary.peakHour}</p>
|
|
339
|
-
</div>
|
|
340
|
-
<div className={`${themeClasses.cardBg} border rounded-lg p-4`}>
|
|
341
|
-
<p className={themeClasses.textSecondary + ' text-sm'}>Avg/Day</p>
|
|
342
|
-
<p className="text-3xl font-bold text-orange-400">{data.summary.avgLoginsPerDay.toLocaleString()}</p>
|
|
343
|
-
</div>
|
|
344
|
-
</div>
|
|
345
|
-
|
|
346
|
-
{/* Time Series Chart */}
|
|
347
|
-
<div className={`${themeClasses.cardBg} border rounded-lg p-6`}>
|
|
348
|
-
<h3 className={`text-lg font-semibold ${themeClasses.textPrimary} mb-4`}>Logins Over Time</h3>
|
|
349
|
-
<TimeSeriesChart series={data.timeSeries} />
|
|
350
|
-
<div className={`flex justify-between mt-2 text-xs ${themeClasses.textMuted}`}>
|
|
351
|
-
<span>{data.timeSeries[0]?.label}</span>
|
|
352
|
-
<span>{data.timeSeries[data.timeSeries.length - 1]?.label}</span>
|
|
353
|
-
</div>
|
|
354
|
-
</div>
|
|
355
|
-
|
|
356
|
-
{/* Geographic Map (optional slot) */}
|
|
357
|
-
{geoMapComponent && (
|
|
358
|
-
<div className={`${themeClasses.cardBg} border rounded-lg p-6`}>
|
|
359
|
-
<h3 className={`text-lg font-semibold ${themeClasses.textPrimary} mb-4`}>Geographic Distribution</h3>
|
|
360
|
-
{geoMapComponent}
|
|
361
|
-
</div>
|
|
362
|
-
)}
|
|
363
|
-
|
|
364
|
-
{/* Charts Grid */}
|
|
365
|
-
<div className="grid md:grid-cols-2 gap-6">
|
|
366
|
-
{/* By Country */}
|
|
367
|
-
<div className={`${themeClasses.cardBg} border rounded-lg p-6`}>
|
|
368
|
-
<h3 className={`text-lg font-semibold ${themeClasses.textPrimary} mb-4`}>Top Countries</h3>
|
|
369
|
-
{data.byCountry.length > 0 ? (
|
|
370
|
-
<div className="space-y-3">
|
|
371
|
-
{data.byCountry.slice(0, 8).map((country, i) => (
|
|
372
|
-
<div key={i} className="flex items-center gap-3">
|
|
373
|
-
<span className="text-xl">{country.flag}</span>
|
|
374
|
-
<span className={`${themeClasses.textPrimary} flex-1`}>{country.country}</span>
|
|
375
|
-
<div className={`w-24 h-4 ${themeClasses.barBg} rounded overflow-hidden`}>
|
|
376
|
-
<div
|
|
377
|
-
className="h-full bg-green-500"
|
|
378
|
-
style={{ width: `${country.percentage}%` }}
|
|
379
|
-
/>
|
|
380
|
-
</div>
|
|
381
|
-
<span className={`${themeClasses.textSecondary} text-sm w-16 text-right`}>
|
|
382
|
-
{country.count} ({country.percentage}%)
|
|
383
|
-
</span>
|
|
384
|
-
</div>
|
|
385
|
-
))}
|
|
386
|
-
</div>
|
|
387
|
-
) : (
|
|
388
|
-
<p className={`${themeClasses.textMuted} text-center py-4`}>No data</p>
|
|
389
|
-
)}
|
|
390
|
-
</div>
|
|
391
|
-
|
|
392
|
-
{/* By Device */}
|
|
393
|
-
<div className={`${themeClasses.cardBg} border rounded-lg p-6`}>
|
|
394
|
-
<h3 className={`text-lg font-semibold ${themeClasses.textPrimary} mb-4`}>Device Types</h3>
|
|
395
|
-
{deviceData.length > 0 ? (
|
|
396
|
-
<div className="space-y-4">
|
|
397
|
-
{deviceData.map((item, i) => {
|
|
398
|
-
const total = deviceData.reduce((sum, d) => sum + d.value, 0);
|
|
399
|
-
const percentage = total > 0 ? Math.round((item.value / total) * 100) : 0;
|
|
400
|
-
const icon = item.label.toLowerCase() === 'mobile' ? '📱' :
|
|
401
|
-
item.label.toLowerCase() === 'tablet' ? '📱' : '💻';
|
|
402
|
-
return (
|
|
403
|
-
<div key={i} className="flex items-center gap-3">
|
|
404
|
-
<span className="text-xl">{icon}</span>
|
|
405
|
-
<span className={`${themeClasses.textPrimary} w-20`}>{item.label}</span>
|
|
406
|
-
<div className={`flex-1 h-6 ${themeClasses.barBg} rounded overflow-hidden`}>
|
|
407
|
-
<div
|
|
408
|
-
className="h-full bg-purple-500"
|
|
409
|
-
style={{ width: `${percentage}%` }}
|
|
410
|
-
/>
|
|
411
|
-
</div>
|
|
412
|
-
<span className={`${themeClasses.textSecondary} text-sm w-20 text-right`}>
|
|
413
|
-
{item.value} ({percentage}%)
|
|
414
|
-
</span>
|
|
415
|
-
</div>
|
|
416
|
-
);
|
|
417
|
-
})}
|
|
418
|
-
</div>
|
|
419
|
-
) : (
|
|
420
|
-
<p className={`${themeClasses.textMuted} text-center py-4`}>No data</p>
|
|
421
|
-
)}
|
|
422
|
-
</div>
|
|
423
|
-
|
|
424
|
-
{/* By Browser */}
|
|
425
|
-
<div className={`${themeClasses.cardBg} border rounded-lg p-6`}>
|
|
426
|
-
<h3 className={`text-lg font-semibold ${themeClasses.textPrimary} mb-4`}>Browsers</h3>
|
|
427
|
-
<BarChart
|
|
428
|
-
data={browserData}
|
|
429
|
-
maxValue={Math.max(...browserData.map(d => d.value), 1)}
|
|
430
|
-
colorClass="bg-orange-500"
|
|
431
|
-
/>
|
|
432
|
-
</div>
|
|
433
|
-
|
|
434
|
-
{/* By OS */}
|
|
435
|
-
<div className={`${themeClasses.cardBg} border rounded-lg p-6`}>
|
|
436
|
-
<h3 className={`text-lg font-semibold ${themeClasses.textPrimary} mb-4`}>Operating Systems</h3>
|
|
437
|
-
<BarChart
|
|
438
|
-
data={osData}
|
|
439
|
-
maxValue={Math.max(...osData.map(d => d.value), 1)}
|
|
440
|
-
colorClass="bg-cyan-500"
|
|
441
|
-
/>
|
|
442
|
-
</div>
|
|
443
|
-
</div>
|
|
444
|
-
|
|
445
|
-
{/* Top Users Table */}
|
|
446
|
-
<div className={`${themeClasses.cardBg} border rounded-lg overflow-hidden`}>
|
|
447
|
-
<div className={`p-4 border-b ${isDark ? 'border-slate-700' : 'border-gray-200'}`}>
|
|
448
|
-
<h3 className={`text-lg font-semibold ${themeClasses.textPrimary}`}>Top Users by Login Count</h3>
|
|
449
|
-
</div>
|
|
450
|
-
<div className="overflow-x-auto">
|
|
451
|
-
<table className="w-full text-sm">
|
|
452
|
-
<thead className={isDark ? 'bg-slate-700/50' : 'bg-gray-50'}>
|
|
453
|
-
<tr>
|
|
454
|
-
<th className={`px-4 py-3 text-left ${themeClasses.textSecondary} font-medium`}>#</th>
|
|
455
|
-
<th className={`px-4 py-3 text-left ${themeClasses.textSecondary} font-medium`}>User</th>
|
|
456
|
-
<th className={`px-4 py-3 text-left ${themeClasses.textSecondary} font-medium`}>Logins</th>
|
|
457
|
-
<th className={`px-4 py-3 text-left ${themeClasses.textSecondary} font-medium`}>Last Login</th>
|
|
458
|
-
</tr>
|
|
459
|
-
</thead>
|
|
460
|
-
<tbody className={`divide-y ${isDark ? 'divide-slate-700' : 'divide-gray-200'}`}>
|
|
461
|
-
{data.topUsers.length > 0 ? (
|
|
462
|
-
data.topUsers.map((user, i) => (
|
|
463
|
-
<tr key={i} className={isDark ? 'hover:bg-slate-700/50' : 'hover:bg-gray-50'}>
|
|
464
|
-
<td className={`px-4 py-3 ${themeClasses.textMuted}`}>{i + 1}</td>
|
|
465
|
-
<td className="px-4 py-3">
|
|
466
|
-
<div>
|
|
467
|
-
<p className={`${themeClasses.textPrimary} font-medium`}>{user.name}</p>
|
|
468
|
-
<p className={`${themeClasses.textSecondary} text-xs`}>{user.email}</p>
|
|
469
|
-
</div>
|
|
470
|
-
</td>
|
|
471
|
-
<td className="px-4 py-3">
|
|
472
|
-
<span className="px-2 py-1 bg-blue-600/20 text-blue-400 rounded text-xs font-medium">
|
|
473
|
-
{user.count}
|
|
474
|
-
</span>
|
|
475
|
-
</td>
|
|
476
|
-
<td className={`px-4 py-3 ${themeClasses.textSecondary}`}>{formatDate(user.lastLogin)}</td>
|
|
477
|
-
</tr>
|
|
478
|
-
))
|
|
479
|
-
) : (
|
|
480
|
-
<tr>
|
|
481
|
-
<td colSpan={4} className={`px-4 py-8 text-center ${themeClasses.textMuted}`}>
|
|
482
|
-
No login data for this period
|
|
483
|
-
</td>
|
|
484
|
-
</tr>
|
|
485
|
-
)}
|
|
486
|
-
</tbody>
|
|
487
|
-
</table>
|
|
488
|
-
</div>
|
|
489
|
-
</div>
|
|
490
|
-
|
|
491
|
-
{/* Business Metrics Section */}
|
|
492
|
-
{showBusinessMetrics && (
|
|
493
|
-
<div className={`border-t ${isDark ? 'border-slate-700' : 'border-gray-200'} pt-6 mt-6`}>
|
|
494
|
-
<h2 className={`text-xl font-bold ${themeClasses.textPrimary} mb-6`}>Business Metrics</h2>
|
|
495
|
-
|
|
496
|
-
{/* Revenue Summary Cards */}
|
|
497
|
-
{revenueData && (
|
|
498
|
-
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
|
|
499
|
-
<div className={`${themeClasses.cardBg} border rounded-lg p-4`}>
|
|
500
|
-
<p className={themeClasses.textSecondary + ' text-sm'}>Monthly Revenue (MRR)</p>
|
|
501
|
-
<p className="text-3xl font-bold text-green-400">${revenueData.mrr.toLocaleString()}</p>
|
|
502
|
-
</div>
|
|
503
|
-
<div className={`${themeClasses.cardBg} border rounded-lg p-4`}>
|
|
504
|
-
<p className={themeClasses.textSecondary + ' text-sm'}>Annual Revenue (ARR)</p>
|
|
505
|
-
<p className="text-3xl font-bold text-green-400">${revenueData.arr.toLocaleString()}</p>
|
|
506
|
-
</div>
|
|
507
|
-
<div className={`${themeClasses.cardBg} border rounded-lg p-4`}>
|
|
508
|
-
<p className={themeClasses.textSecondary + ' text-sm'}>Paid Subscribers</p>
|
|
509
|
-
<p className="text-3xl font-bold text-blue-400">{revenueData.paidSubscribers.toLocaleString()}</p>
|
|
510
|
-
</div>
|
|
511
|
-
<div className={`${themeClasses.cardBg} border rounded-lg p-4`}>
|
|
512
|
-
<p className={themeClasses.textSecondary + ' text-sm'}>Conversion Rate</p>
|
|
513
|
-
<p className="text-3xl font-bold text-purple-400">{revenueData.conversionRate}%</p>
|
|
514
|
-
</div>
|
|
515
|
-
</div>
|
|
516
|
-
)}
|
|
517
|
-
|
|
518
|
-
<div className="grid md:grid-cols-2 gap-6">
|
|
519
|
-
{/* Tier Distribution - Donut Chart */}
|
|
520
|
-
{tierData && (
|
|
521
|
-
<div className={`${themeClasses.cardBg} border rounded-lg p-6`}>
|
|
522
|
-
<h3 className={`text-lg font-semibold ${themeClasses.textPrimary} mb-4`}>Tier Distribution</h3>
|
|
523
|
-
<div className="flex items-center gap-8">
|
|
524
|
-
{/* Donut Chart */}
|
|
525
|
-
<div className="relative w-40 h-40">
|
|
526
|
-
<svg viewBox="0 0 100 100" className="transform -rotate-90">
|
|
527
|
-
{(() => {
|
|
528
|
-
const colors: Record<string, string> = {
|
|
529
|
-
free: '#64748b',
|
|
530
|
-
premium: '#3b82f6',
|
|
531
|
-
ultimate: '#f59e0b',
|
|
532
|
-
enterprise: '#8b5cf6',
|
|
533
|
-
};
|
|
534
|
-
let offset = 0;
|
|
535
|
-
return tierData.distribution.map((tier) => {
|
|
536
|
-
const strokeDasharray = `${tier.percentage} ${100 - tier.percentage}`;
|
|
537
|
-
const strokeDashoffset = -offset;
|
|
538
|
-
offset += tier.percentage;
|
|
539
|
-
return (
|
|
540
|
-
<circle
|
|
541
|
-
key={tier.tier}
|
|
542
|
-
cx="50"
|
|
543
|
-
cy="50"
|
|
544
|
-
r="40"
|
|
545
|
-
fill="transparent"
|
|
546
|
-
stroke={colors[tier.tier] || '#64748b'}
|
|
547
|
-
strokeWidth="20"
|
|
548
|
-
strokeDasharray={strokeDasharray}
|
|
549
|
-
strokeDashoffset={strokeDashoffset}
|
|
550
|
-
/>
|
|
551
|
-
);
|
|
552
|
-
});
|
|
553
|
-
})()}
|
|
554
|
-
</svg>
|
|
555
|
-
<div className="absolute inset-0 flex items-center justify-center">
|
|
556
|
-
<div className="text-center">
|
|
557
|
-
<p className={`text-2xl font-bold ${themeClasses.textPrimary}`}>{tierData.total}</p>
|
|
558
|
-
<p className={`text-xs ${themeClasses.textSecondary}`}>Total</p>
|
|
559
|
-
</div>
|
|
560
|
-
</div>
|
|
561
|
-
</div>
|
|
562
|
-
{/* Legend */}
|
|
563
|
-
<div className="flex-1 space-y-2">
|
|
564
|
-
{tierData.distribution.map((tier) => {
|
|
565
|
-
const colors: Record<string, string> = {
|
|
566
|
-
free: 'bg-slate-500',
|
|
567
|
-
premium: 'bg-blue-500',
|
|
568
|
-
ultimate: 'bg-amber-500',
|
|
569
|
-
enterprise: 'bg-purple-500',
|
|
570
|
-
};
|
|
571
|
-
return (
|
|
572
|
-
<div key={tier.tier} className="flex items-center justify-between">
|
|
573
|
-
<div className="flex items-center gap-2">
|
|
574
|
-
<div className={`w-3 h-3 rounded-full ${colors[tier.tier] || 'bg-slate-500'}`} />
|
|
575
|
-
<span className={`${themeClasses.textPrimary} capitalize`}>{tier.tier}</span>
|
|
576
|
-
</div>
|
|
577
|
-
<div className="text-right">
|
|
578
|
-
<span className={`${themeClasses.textPrimary} font-medium`}>{tier.count}</span>
|
|
579
|
-
<span className={`${themeClasses.textMuted} text-sm ml-2`}>({tier.percentage}%)</span>
|
|
580
|
-
</div>
|
|
581
|
-
</div>
|
|
582
|
-
);
|
|
583
|
-
})}
|
|
584
|
-
</div>
|
|
585
|
-
</div>
|
|
586
|
-
</div>
|
|
587
|
-
)}
|
|
588
|
-
|
|
589
|
-
{/* Feature Usage Chart */}
|
|
590
|
-
{featureData && (
|
|
591
|
-
<div className={`${themeClasses.cardBg} border rounded-lg p-6`}>
|
|
592
|
-
<h3 className={`text-lg font-semibold ${themeClasses.textPrimary} mb-4`}>Feature Usage</h3>
|
|
593
|
-
<div className="space-y-3">
|
|
594
|
-
{featureData.features.map((feature) => {
|
|
595
|
-
const maxUses = Math.max(...featureData.features.map(f => f.uses), 1);
|
|
596
|
-
const percentage = (feature.uses / maxUses) * 100;
|
|
597
|
-
return (
|
|
598
|
-
<div key={feature.name}>
|
|
599
|
-
<div className="flex justify-between items-center mb-1">
|
|
600
|
-
<span className={`${themeClasses.textPrimary} text-sm`}>{feature.name}</span>
|
|
601
|
-
<span className={`${themeClasses.textSecondary} text-xs`}>{feature.uses.toLocaleString()} uses</span>
|
|
602
|
-
</div>
|
|
603
|
-
<div className={`h-4 ${themeClasses.barBg} rounded overflow-hidden`}>
|
|
604
|
-
<div
|
|
605
|
-
className="h-full bg-gradient-to-r from-indigo-500 to-purple-500 transition-all duration-300"
|
|
606
|
-
style={{ width: `${percentage}%` }}
|
|
607
|
-
/>
|
|
608
|
-
</div>
|
|
609
|
-
</div>
|
|
610
|
-
);
|
|
611
|
-
})}
|
|
612
|
-
</div>
|
|
613
|
-
</div>
|
|
614
|
-
)}
|
|
615
|
-
</div>
|
|
616
|
-
|
|
617
|
-
{/* Revenue by Tier */}
|
|
618
|
-
{revenueData && revenueData.revenueByTier.length > 0 && (
|
|
619
|
-
<div className={`mt-6 ${themeClasses.cardBg} border rounded-lg overflow-hidden`}>
|
|
620
|
-
<div className={`p-4 border-b ${isDark ? 'border-slate-700' : 'border-gray-200'}`}>
|
|
621
|
-
<h3 className={`text-lg font-semibold ${themeClasses.textPrimary}`}>Revenue by Tier</h3>
|
|
622
|
-
</div>
|
|
623
|
-
<div className="overflow-x-auto">
|
|
624
|
-
<table className="w-full text-sm">
|
|
625
|
-
<thead className={isDark ? 'bg-slate-700/50' : 'bg-gray-50'}>
|
|
626
|
-
<tr>
|
|
627
|
-
<th className={`px-4 py-3 text-left ${themeClasses.textSecondary} font-medium`}>Tier</th>
|
|
628
|
-
<th className={`px-4 py-3 text-left ${themeClasses.textSecondary} font-medium`}>Subscribers</th>
|
|
629
|
-
<th className={`px-4 py-3 text-left ${themeClasses.textSecondary} font-medium`}>Price/Mo</th>
|
|
630
|
-
<th className={`px-4 py-3 text-left ${themeClasses.textSecondary} font-medium`}>MRR</th>
|
|
631
|
-
<th className={`px-4 py-3 text-left ${themeClasses.textSecondary} font-medium`}>% of Revenue</th>
|
|
632
|
-
</tr>
|
|
633
|
-
</thead>
|
|
634
|
-
<tbody className={`divide-y ${isDark ? 'divide-slate-700' : 'divide-gray-200'}`}>
|
|
635
|
-
{revenueData.revenueByTier.map((tier) => {
|
|
636
|
-
const totalMrr = revenueData.revenueByTier.reduce((sum, t) => sum + t.mrr, 0);
|
|
637
|
-
const percentage = totalMrr > 0 ? Math.round((tier.mrr / totalMrr) * 100) : 0;
|
|
638
|
-
const tierColors: Record<string, string> = {
|
|
639
|
-
premium: 'text-blue-400',
|
|
640
|
-
ultimate: 'text-amber-400',
|
|
641
|
-
enterprise: 'text-purple-400',
|
|
642
|
-
};
|
|
643
|
-
return (
|
|
644
|
-
<tr key={tier.tier} className={isDark ? 'hover:bg-slate-700/50' : 'hover:bg-gray-50'}>
|
|
645
|
-
<td className={`px-4 py-3 font-medium capitalize ${tierColors[tier.tier] || themeClasses.textPrimary}`}>
|
|
646
|
-
{tier.tier}
|
|
647
|
-
</td>
|
|
648
|
-
<td className={`px-4 py-3 ${themeClasses.textPrimary}`}>{tier.subscribers.toLocaleString()}</td>
|
|
649
|
-
<td className={`px-4 py-3 ${themeClasses.textSecondary}`}>${tier.pricePerMonth}</td>
|
|
650
|
-
<td className="px-4 py-3 text-green-400 font-medium">${tier.mrr.toLocaleString()}</td>
|
|
651
|
-
<td className="px-4 py-3">
|
|
652
|
-
<div className="flex items-center gap-2">
|
|
653
|
-
<div className={`w-16 h-2 ${themeClasses.barBg} rounded overflow-hidden`}>
|
|
654
|
-
<div className="h-full bg-green-500" style={{ width: `${percentage}%` }} />
|
|
655
|
-
</div>
|
|
656
|
-
<span className={`${themeClasses.textSecondary} text-xs`}>{percentage}%</span>
|
|
657
|
-
</div>
|
|
658
|
-
</td>
|
|
659
|
-
</tr>
|
|
660
|
-
);
|
|
661
|
-
})}
|
|
662
|
-
</tbody>
|
|
663
|
-
</table>
|
|
664
|
-
</div>
|
|
665
|
-
</div>
|
|
666
|
-
)}
|
|
667
|
-
|
|
668
|
-
{/* MRR Trend Chart */}
|
|
669
|
-
{revenueData && revenueData.trend.length > 0 && (
|
|
670
|
-
<div className={`mt-6 ${themeClasses.cardBg} border rounded-lg p-6`}>
|
|
671
|
-
<h3 className={`text-lg font-semibold ${themeClasses.textPrimary} mb-4`}>MRR Trend (6 Months)</h3>
|
|
672
|
-
<div className="h-48 flex items-end gap-2">
|
|
673
|
-
{revenueData.trend.map((point, i) => {
|
|
674
|
-
const maxMrr = Math.max(...revenueData.trend.map(p => p.mrr), 1);
|
|
675
|
-
const height = (point.mrr / maxMrr) * 100;
|
|
676
|
-
return (
|
|
677
|
-
<div key={i} className="flex-1 flex flex-col items-center group">
|
|
678
|
-
<div className="relative w-full">
|
|
679
|
-
<div
|
|
680
|
-
className="w-full bg-gradient-to-t from-green-600 to-green-400 rounded-t transition-all duration-300 hover:from-green-500 hover:to-green-300"
|
|
681
|
-
style={{ height: `${height * 1.8}px`, minHeight: '4px' }}
|
|
682
|
-
/>
|
|
683
|
-
{/* Tooltip */}
|
|
684
|
-
<div className="absolute bottom-full mb-2 left-1/2 -translate-x-1/2 hidden group-hover:block z-10">
|
|
685
|
-
<div className={`${isDark ? 'bg-slate-900' : 'bg-gray-800'} text-white text-xs px-2 py-1 rounded shadow-lg whitespace-nowrap`}>
|
|
686
|
-
${point.mrr.toLocaleString()}
|
|
687
|
-
</div>
|
|
688
|
-
</div>
|
|
689
|
-
</div>
|
|
690
|
-
<span className={`text-xs ${themeClasses.textMuted} mt-2`}>{point.month}</span>
|
|
691
|
-
</div>
|
|
692
|
-
);
|
|
693
|
-
})}
|
|
694
|
-
</div>
|
|
695
|
-
</div>
|
|
696
|
-
)}
|
|
697
|
-
</div>
|
|
698
|
-
)}
|
|
699
|
-
</div>
|
|
700
|
-
);
|
|
701
|
-
}
|
|
702
|
-
|
|
703
|
-
export default AnalyticsTab;
|
|
1
|
+
/**
|
|
2
|
+
* =============================================================================
|
|
3
|
+
* VIBE ADMIN ANALYTICS TAB
|
|
4
|
+
* =============================================================================
|
|
5
|
+
*
|
|
6
|
+
* Generic dashboard tab showing login statistics and analytics.
|
|
7
|
+
* Can be extended by consumer apps with custom components.
|
|
8
|
+
*
|
|
9
|
+
* =============================================================================
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
'use client';
|
|
13
|
+
|
|
14
|
+
import React, { useState, useEffect, useCallback, ReactNode } from 'react';
|
|
15
|
+
|
|
16
|
+
// -----------------------------------------------------------------------------
|
|
17
|
+
// TYPES
|
|
18
|
+
// -----------------------------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
type Period = 'today' | 'week' | 'month' | 'year';
|
|
21
|
+
|
|
22
|
+
interface TimeSeries {
|
|
23
|
+
date: string;
|
|
24
|
+
count: number;
|
|
25
|
+
label: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface CountryStats {
|
|
29
|
+
country: string;
|
|
30
|
+
code: string;
|
|
31
|
+
flag: string;
|
|
32
|
+
count: number;
|
|
33
|
+
percentage: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface UserStats {
|
|
37
|
+
email: string;
|
|
38
|
+
name: string;
|
|
39
|
+
count: number;
|
|
40
|
+
lastLogin: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
interface TierDistribution {
|
|
44
|
+
tier: string;
|
|
45
|
+
count: number;
|
|
46
|
+
percentage: number;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
interface FeatureUsage {
|
|
50
|
+
name: string;
|
|
51
|
+
uses: number;
|
|
52
|
+
uniqueUsers: number;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
interface RevenueByTier {
|
|
56
|
+
tier: string;
|
|
57
|
+
subscribers: number;
|
|
58
|
+
pricePerMonth: number;
|
|
59
|
+
mrr: number;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
interface RevenueTrend {
|
|
63
|
+
month: string;
|
|
64
|
+
mrr: number;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
interface TierData {
|
|
68
|
+
distribution: TierDistribution[];
|
|
69
|
+
total: number;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
interface FeatureData {
|
|
73
|
+
features: FeatureUsage[];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
interface RevenueData {
|
|
77
|
+
mrr: number;
|
|
78
|
+
arr: number;
|
|
79
|
+
paidSubscribers: number;
|
|
80
|
+
totalUsers: number;
|
|
81
|
+
conversionRate: number;
|
|
82
|
+
revenueByTier: RevenueByTier[];
|
|
83
|
+
trend: RevenueTrend[];
|
|
84
|
+
currency: string;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
interface AnalyticsData {
|
|
88
|
+
period: Period;
|
|
89
|
+
dateRange: {
|
|
90
|
+
start: string;
|
|
91
|
+
end: string;
|
|
92
|
+
};
|
|
93
|
+
summary: {
|
|
94
|
+
totalLogins: number;
|
|
95
|
+
uniqueUsers: number;
|
|
96
|
+
newUsers: number;
|
|
97
|
+
peakHour: string;
|
|
98
|
+
avgLoginsPerDay: number;
|
|
99
|
+
};
|
|
100
|
+
timeSeries: TimeSeries[];
|
|
101
|
+
byCountry: CountryStats[];
|
|
102
|
+
byDevice: Record<string, number>;
|
|
103
|
+
byBrowser: Record<string, number>;
|
|
104
|
+
byOS: Record<string, number>;
|
|
105
|
+
topUsers: UserStats[];
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export interface AnalyticsTabProps {
|
|
109
|
+
isDark?: boolean;
|
|
110
|
+
/** Base API path for analytics (default: /api/admin/analytics) */
|
|
111
|
+
apiBasePath?: string;
|
|
112
|
+
/** Optional custom component to render in the geo section (e.g., WorldMap) */
|
|
113
|
+
geoMapComponent?: ReactNode;
|
|
114
|
+
/** Show business metrics section (default: true) */
|
|
115
|
+
showBusinessMetrics?: boolean;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// -----------------------------------------------------------------------------
|
|
119
|
+
// COMPONENT
|
|
120
|
+
// -----------------------------------------------------------------------------
|
|
121
|
+
|
|
122
|
+
export function AnalyticsTab({
|
|
123
|
+
isDark = true,
|
|
124
|
+
apiBasePath = '/api/admin/analytics',
|
|
125
|
+
geoMapComponent,
|
|
126
|
+
showBusinessMetrics = true,
|
|
127
|
+
}: AnalyticsTabProps) {
|
|
128
|
+
const [period, setPeriod] = useState<Period>('week');
|
|
129
|
+
const [data, setData] = useState<AnalyticsData | null>(null);
|
|
130
|
+
const [loading, setLoading] = useState(true);
|
|
131
|
+
const [error, setError] = useState<string | null>(null);
|
|
132
|
+
|
|
133
|
+
// Business metrics data
|
|
134
|
+
const [tierData, setTierData] = useState<TierData | null>(null);
|
|
135
|
+
const [featureData, setFeatureData] = useState<FeatureData | null>(null);
|
|
136
|
+
const [revenueData, setRevenueData] = useState<RevenueData | null>(null);
|
|
137
|
+
|
|
138
|
+
const themeClasses = {
|
|
139
|
+
cardBg: isDark ? 'bg-slate-800 border-slate-700' : 'bg-white border-gray-200 shadow-sm',
|
|
140
|
+
textPrimary: isDark ? 'text-white' : 'text-gray-900',
|
|
141
|
+
textSecondary: isDark ? 'text-gray-400' : 'text-gray-600',
|
|
142
|
+
textMuted: isDark ? 'text-gray-500' : 'text-gray-500',
|
|
143
|
+
barBg: isDark ? 'bg-slate-700' : 'bg-gray-200',
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
const fetchAnalytics = useCallback(async () => {
|
|
147
|
+
setLoading(true);
|
|
148
|
+
setError(null);
|
|
149
|
+
try {
|
|
150
|
+
const res = await fetch(`${apiBasePath}/logins?period=${period}`);
|
|
151
|
+
if (!res.ok) throw new Error('Failed to fetch analytics');
|
|
152
|
+
const json = await res.json();
|
|
153
|
+
setData(json);
|
|
154
|
+
} catch (err: any) {
|
|
155
|
+
setError(err.message);
|
|
156
|
+
} finally {
|
|
157
|
+
setLoading(false);
|
|
158
|
+
}
|
|
159
|
+
}, [period, apiBasePath]);
|
|
160
|
+
|
|
161
|
+
// Fetch tier, feature, and revenue data
|
|
162
|
+
const fetchBusinessMetrics = useCallback(async () => {
|
|
163
|
+
if (!showBusinessMetrics) return;
|
|
164
|
+
|
|
165
|
+
try {
|
|
166
|
+
const [tierRes, featureRes, revenueRes] = await Promise.all([
|
|
167
|
+
fetch(`${apiBasePath}/tiers`),
|
|
168
|
+
fetch(`${apiBasePath}/feature-usage`),
|
|
169
|
+
fetch(`${apiBasePath}/revenue`),
|
|
170
|
+
]);
|
|
171
|
+
|
|
172
|
+
if (tierRes.ok) {
|
|
173
|
+
const data = await tierRes.json();
|
|
174
|
+
setTierData(data);
|
|
175
|
+
}
|
|
176
|
+
if (featureRes.ok) {
|
|
177
|
+
const data = await featureRes.json();
|
|
178
|
+
setFeatureData(data);
|
|
179
|
+
}
|
|
180
|
+
if (revenueRes.ok) {
|
|
181
|
+
const data = await revenueRes.json();
|
|
182
|
+
setRevenueData(data);
|
|
183
|
+
}
|
|
184
|
+
} catch (err) {
|
|
185
|
+
console.error('Failed to fetch business metrics:', err);
|
|
186
|
+
}
|
|
187
|
+
}, [apiBasePath, showBusinessMetrics]);
|
|
188
|
+
|
|
189
|
+
useEffect(() => {
|
|
190
|
+
fetchAnalytics();
|
|
191
|
+
fetchBusinessMetrics();
|
|
192
|
+
}, [fetchAnalytics, fetchBusinessMetrics]);
|
|
193
|
+
|
|
194
|
+
const formatDate = (dateString: string) => {
|
|
195
|
+
return new Date(dateString).toLocaleDateString('en-US', {
|
|
196
|
+
month: 'short',
|
|
197
|
+
day: 'numeric',
|
|
198
|
+
hour: '2-digit',
|
|
199
|
+
minute: '2-digit',
|
|
200
|
+
});
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
// Simple bar chart component
|
|
204
|
+
const BarChart = ({
|
|
205
|
+
data,
|
|
206
|
+
maxValue,
|
|
207
|
+
colorClass = 'bg-blue-500'
|
|
208
|
+
}: {
|
|
209
|
+
data: { label: string; value: number }[];
|
|
210
|
+
maxValue: number;
|
|
211
|
+
colorClass?: string;
|
|
212
|
+
}) => (
|
|
213
|
+
<div className="space-y-2">
|
|
214
|
+
{data.map((item, i) => (
|
|
215
|
+
<div key={i} className="flex items-center gap-2">
|
|
216
|
+
<span className={`text-xs ${themeClasses.textSecondary} w-20 truncate`}>{item.label}</span>
|
|
217
|
+
<div className={`flex-1 h-6 ${themeClasses.barBg} rounded overflow-hidden`}>
|
|
218
|
+
<div
|
|
219
|
+
className={`h-full ${colorClass} transition-all duration-300`}
|
|
220
|
+
style={{ width: `${maxValue > 0 ? (item.value / maxValue) * 100 : 0}%` }}
|
|
221
|
+
/>
|
|
222
|
+
</div>
|
|
223
|
+
<span className={`text-xs ${themeClasses.textPrimary} w-12 text-right`}>{item.value}</span>
|
|
224
|
+
</div>
|
|
225
|
+
))}
|
|
226
|
+
</div>
|
|
227
|
+
);
|
|
228
|
+
|
|
229
|
+
// Time series line chart (simplified as bar chart)
|
|
230
|
+
const TimeSeriesChart = ({ series }: { series: TimeSeries[] }) => {
|
|
231
|
+
const maxCount = Math.max(...series.map(s => s.count), 1);
|
|
232
|
+
return (
|
|
233
|
+
<div className="h-48 flex items-end gap-1">
|
|
234
|
+
{series.map((item, i) => (
|
|
235
|
+
<div
|
|
236
|
+
key={i}
|
|
237
|
+
className="flex-1 flex flex-col items-center group relative"
|
|
238
|
+
>
|
|
239
|
+
<div
|
|
240
|
+
className="w-full bg-blue-500 rounded-t transition-all duration-300 hover:bg-blue-400"
|
|
241
|
+
style={{ height: `${(item.count / maxCount) * 100}%`, minHeight: item.count > 0 ? '4px' : '0' }}
|
|
242
|
+
/>
|
|
243
|
+
{/* Tooltip */}
|
|
244
|
+
<div className="absolute bottom-full mb-2 hidden group-hover:block z-10">
|
|
245
|
+
<div className={`${isDark ? 'bg-slate-900' : 'bg-gray-800'} text-white text-xs px-2 py-1 rounded shadow-lg whitespace-nowrap`}>
|
|
246
|
+
{item.label}: {item.count}
|
|
247
|
+
</div>
|
|
248
|
+
</div>
|
|
249
|
+
</div>
|
|
250
|
+
))}
|
|
251
|
+
</div>
|
|
252
|
+
);
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
if (loading && !data) {
|
|
256
|
+
return (
|
|
257
|
+
<div className="flex items-center justify-center py-12">
|
|
258
|
+
<svg className="animate-spin h-8 w-8 text-blue-400" viewBox="0 0 24 24" fill="none">
|
|
259
|
+
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
|
260
|
+
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" />
|
|
261
|
+
</svg>
|
|
262
|
+
</div>
|
|
263
|
+
);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
if (error) {
|
|
267
|
+
return (
|
|
268
|
+
<div className={`${isDark ? 'bg-red-900/30 border-red-700' : 'bg-red-50 border-red-300'} border rounded-lg p-4 ${isDark ? 'text-red-300' : 'text-red-700'}`}>
|
|
269
|
+
{error}
|
|
270
|
+
<button onClick={fetchAnalytics} className="ml-4 underline">Retry</button>
|
|
271
|
+
</div>
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
if (!data) return null;
|
|
276
|
+
|
|
277
|
+
const deviceData = Object.entries(data.byDevice)
|
|
278
|
+
.map(([label, value]) => ({ label: label.charAt(0).toUpperCase() + label.slice(1), value }))
|
|
279
|
+
.sort((a, b) => b.value - a.value);
|
|
280
|
+
|
|
281
|
+
const browserData = Object.entries(data.byBrowser)
|
|
282
|
+
.map(([label, value]) => ({ label, value }))
|
|
283
|
+
.sort((a, b) => b.value - a.value)
|
|
284
|
+
.slice(0, 5);
|
|
285
|
+
|
|
286
|
+
const osData = Object.entries(data.byOS)
|
|
287
|
+
.map(([label, value]) => ({ label, value }))
|
|
288
|
+
.sort((a, b) => b.value - a.value)
|
|
289
|
+
.slice(0, 5);
|
|
290
|
+
|
|
291
|
+
return (
|
|
292
|
+
<div className="space-y-6">
|
|
293
|
+
{/* Period Selector */}
|
|
294
|
+
<div className="flex items-center justify-between">
|
|
295
|
+
<div className="flex gap-2">
|
|
296
|
+
{(['today', 'week', 'month', 'year'] as Period[]).map((p) => (
|
|
297
|
+
<button
|
|
298
|
+
key={p}
|
|
299
|
+
onClick={() => setPeriod(p)}
|
|
300
|
+
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
|
301
|
+
period === p
|
|
302
|
+
? 'bg-blue-600 text-white'
|
|
303
|
+
: isDark ? 'bg-slate-700 text-gray-300 hover:bg-slate-600' : 'bg-gray-200 text-gray-700 hover:bg-gray-300'
|
|
304
|
+
}`}
|
|
305
|
+
>
|
|
306
|
+
{p === 'today' ? 'Today' : p === 'week' ? 'This Week' : p === 'month' ? 'This Month' : 'This Year'}
|
|
307
|
+
</button>
|
|
308
|
+
))}
|
|
309
|
+
</div>
|
|
310
|
+
<button
|
|
311
|
+
onClick={fetchAnalytics}
|
|
312
|
+
disabled={loading}
|
|
313
|
+
className={`p-2 ${themeClasses.textSecondary} hover:${themeClasses.textPrimary} transition-colors disabled:opacity-50`}
|
|
314
|
+
title="Refresh"
|
|
315
|
+
>
|
|
316
|
+
<svg className={`w-5 h-5 ${loading ? 'animate-spin' : ''}`} fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
317
|
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
|
318
|
+
</svg>
|
|
319
|
+
</button>
|
|
320
|
+
</div>
|
|
321
|
+
|
|
322
|
+
{/* Summary Cards */}
|
|
323
|
+
<div className="grid grid-cols-2 md:grid-cols-5 gap-4">
|
|
324
|
+
<div className={`${themeClasses.cardBg} border rounded-lg p-4`}>
|
|
325
|
+
<p className={themeClasses.textSecondary + ' text-sm'}>Total Logins</p>
|
|
326
|
+
<p className={`text-3xl font-bold ${themeClasses.textPrimary}`}>{data.summary.totalLogins.toLocaleString()}</p>
|
|
327
|
+
</div>
|
|
328
|
+
<div className={`${themeClasses.cardBg} border rounded-lg p-4`}>
|
|
329
|
+
<p className={themeClasses.textSecondary + ' text-sm'}>Unique Users</p>
|
|
330
|
+
<p className="text-3xl font-bold text-blue-400">{data.summary.uniqueUsers.toLocaleString()}</p>
|
|
331
|
+
</div>
|
|
332
|
+
<div className={`${themeClasses.cardBg} border rounded-lg p-4`}>
|
|
333
|
+
<p className={themeClasses.textSecondary + ' text-sm'}>New Users</p>
|
|
334
|
+
<p className="text-3xl font-bold text-green-400">{data.summary.newUsers.toLocaleString()}</p>
|
|
335
|
+
</div>
|
|
336
|
+
<div className={`${themeClasses.cardBg} border rounded-lg p-4`}>
|
|
337
|
+
<p className={themeClasses.textSecondary + ' text-sm'}>Peak Hour</p>
|
|
338
|
+
<p className="text-3xl font-bold text-purple-400">{data.summary.peakHour}</p>
|
|
339
|
+
</div>
|
|
340
|
+
<div className={`${themeClasses.cardBg} border rounded-lg p-4`}>
|
|
341
|
+
<p className={themeClasses.textSecondary + ' text-sm'}>Avg/Day</p>
|
|
342
|
+
<p className="text-3xl font-bold text-orange-400">{data.summary.avgLoginsPerDay.toLocaleString()}</p>
|
|
343
|
+
</div>
|
|
344
|
+
</div>
|
|
345
|
+
|
|
346
|
+
{/* Time Series Chart */}
|
|
347
|
+
<div className={`${themeClasses.cardBg} border rounded-lg p-6`}>
|
|
348
|
+
<h3 className={`text-lg font-semibold ${themeClasses.textPrimary} mb-4`}>Logins Over Time</h3>
|
|
349
|
+
<TimeSeriesChart series={data.timeSeries} />
|
|
350
|
+
<div className={`flex justify-between mt-2 text-xs ${themeClasses.textMuted}`}>
|
|
351
|
+
<span>{data.timeSeries[0]?.label}</span>
|
|
352
|
+
<span>{data.timeSeries[data.timeSeries.length - 1]?.label}</span>
|
|
353
|
+
</div>
|
|
354
|
+
</div>
|
|
355
|
+
|
|
356
|
+
{/* Geographic Map (optional slot) */}
|
|
357
|
+
{geoMapComponent && (
|
|
358
|
+
<div className={`${themeClasses.cardBg} border rounded-lg p-6`}>
|
|
359
|
+
<h3 className={`text-lg font-semibold ${themeClasses.textPrimary} mb-4`}>Geographic Distribution</h3>
|
|
360
|
+
{geoMapComponent}
|
|
361
|
+
</div>
|
|
362
|
+
)}
|
|
363
|
+
|
|
364
|
+
{/* Charts Grid */}
|
|
365
|
+
<div className="grid md:grid-cols-2 gap-6">
|
|
366
|
+
{/* By Country */}
|
|
367
|
+
<div className={`${themeClasses.cardBg} border rounded-lg p-6`}>
|
|
368
|
+
<h3 className={`text-lg font-semibold ${themeClasses.textPrimary} mb-4`}>Top Countries</h3>
|
|
369
|
+
{data.byCountry.length > 0 ? (
|
|
370
|
+
<div className="space-y-3">
|
|
371
|
+
{data.byCountry.slice(0, 8).map((country, i) => (
|
|
372
|
+
<div key={i} className="flex items-center gap-3">
|
|
373
|
+
<span className="text-xl">{country.flag}</span>
|
|
374
|
+
<span className={`${themeClasses.textPrimary} flex-1`}>{country.country}</span>
|
|
375
|
+
<div className={`w-24 h-4 ${themeClasses.barBg} rounded overflow-hidden`}>
|
|
376
|
+
<div
|
|
377
|
+
className="h-full bg-green-500"
|
|
378
|
+
style={{ width: `${country.percentage}%` }}
|
|
379
|
+
/>
|
|
380
|
+
</div>
|
|
381
|
+
<span className={`${themeClasses.textSecondary} text-sm w-16 text-right`}>
|
|
382
|
+
{country.count} ({country.percentage}%)
|
|
383
|
+
</span>
|
|
384
|
+
</div>
|
|
385
|
+
))}
|
|
386
|
+
</div>
|
|
387
|
+
) : (
|
|
388
|
+
<p className={`${themeClasses.textMuted} text-center py-4`}>No data</p>
|
|
389
|
+
)}
|
|
390
|
+
</div>
|
|
391
|
+
|
|
392
|
+
{/* By Device */}
|
|
393
|
+
<div className={`${themeClasses.cardBg} border rounded-lg p-6`}>
|
|
394
|
+
<h3 className={`text-lg font-semibold ${themeClasses.textPrimary} mb-4`}>Device Types</h3>
|
|
395
|
+
{deviceData.length > 0 ? (
|
|
396
|
+
<div className="space-y-4">
|
|
397
|
+
{deviceData.map((item, i) => {
|
|
398
|
+
const total = deviceData.reduce((sum, d) => sum + d.value, 0);
|
|
399
|
+
const percentage = total > 0 ? Math.round((item.value / total) * 100) : 0;
|
|
400
|
+
const icon = item.label.toLowerCase() === 'mobile' ? '📱' :
|
|
401
|
+
item.label.toLowerCase() === 'tablet' ? '📱' : '💻';
|
|
402
|
+
return (
|
|
403
|
+
<div key={i} className="flex items-center gap-3">
|
|
404
|
+
<span className="text-xl">{icon}</span>
|
|
405
|
+
<span className={`${themeClasses.textPrimary} w-20`}>{item.label}</span>
|
|
406
|
+
<div className={`flex-1 h-6 ${themeClasses.barBg} rounded overflow-hidden`}>
|
|
407
|
+
<div
|
|
408
|
+
className="h-full bg-purple-500"
|
|
409
|
+
style={{ width: `${percentage}%` }}
|
|
410
|
+
/>
|
|
411
|
+
</div>
|
|
412
|
+
<span className={`${themeClasses.textSecondary} text-sm w-20 text-right`}>
|
|
413
|
+
{item.value} ({percentage}%)
|
|
414
|
+
</span>
|
|
415
|
+
</div>
|
|
416
|
+
);
|
|
417
|
+
})}
|
|
418
|
+
</div>
|
|
419
|
+
) : (
|
|
420
|
+
<p className={`${themeClasses.textMuted} text-center py-4`}>No data</p>
|
|
421
|
+
)}
|
|
422
|
+
</div>
|
|
423
|
+
|
|
424
|
+
{/* By Browser */}
|
|
425
|
+
<div className={`${themeClasses.cardBg} border rounded-lg p-6`}>
|
|
426
|
+
<h3 className={`text-lg font-semibold ${themeClasses.textPrimary} mb-4`}>Browsers</h3>
|
|
427
|
+
<BarChart
|
|
428
|
+
data={browserData}
|
|
429
|
+
maxValue={Math.max(...browserData.map(d => d.value), 1)}
|
|
430
|
+
colorClass="bg-orange-500"
|
|
431
|
+
/>
|
|
432
|
+
</div>
|
|
433
|
+
|
|
434
|
+
{/* By OS */}
|
|
435
|
+
<div className={`${themeClasses.cardBg} border rounded-lg p-6`}>
|
|
436
|
+
<h3 className={`text-lg font-semibold ${themeClasses.textPrimary} mb-4`}>Operating Systems</h3>
|
|
437
|
+
<BarChart
|
|
438
|
+
data={osData}
|
|
439
|
+
maxValue={Math.max(...osData.map(d => d.value), 1)}
|
|
440
|
+
colorClass="bg-cyan-500"
|
|
441
|
+
/>
|
|
442
|
+
</div>
|
|
443
|
+
</div>
|
|
444
|
+
|
|
445
|
+
{/* Top Users Table */}
|
|
446
|
+
<div className={`${themeClasses.cardBg} border rounded-lg overflow-hidden`}>
|
|
447
|
+
<div className={`p-4 border-b ${isDark ? 'border-slate-700' : 'border-gray-200'}`}>
|
|
448
|
+
<h3 className={`text-lg font-semibold ${themeClasses.textPrimary}`}>Top Users by Login Count</h3>
|
|
449
|
+
</div>
|
|
450
|
+
<div className="overflow-x-auto">
|
|
451
|
+
<table className="w-full text-sm">
|
|
452
|
+
<thead className={isDark ? 'bg-slate-700/50' : 'bg-gray-50'}>
|
|
453
|
+
<tr>
|
|
454
|
+
<th className={`px-4 py-3 text-left ${themeClasses.textSecondary} font-medium`}>#</th>
|
|
455
|
+
<th className={`px-4 py-3 text-left ${themeClasses.textSecondary} font-medium`}>User</th>
|
|
456
|
+
<th className={`px-4 py-3 text-left ${themeClasses.textSecondary} font-medium`}>Logins</th>
|
|
457
|
+
<th className={`px-4 py-3 text-left ${themeClasses.textSecondary} font-medium`}>Last Login</th>
|
|
458
|
+
</tr>
|
|
459
|
+
</thead>
|
|
460
|
+
<tbody className={`divide-y ${isDark ? 'divide-slate-700' : 'divide-gray-200'}`}>
|
|
461
|
+
{data.topUsers.length > 0 ? (
|
|
462
|
+
data.topUsers.map((user, i) => (
|
|
463
|
+
<tr key={i} className={isDark ? 'hover:bg-slate-700/50' : 'hover:bg-gray-50'}>
|
|
464
|
+
<td className={`px-4 py-3 ${themeClasses.textMuted}`}>{i + 1}</td>
|
|
465
|
+
<td className="px-4 py-3">
|
|
466
|
+
<div>
|
|
467
|
+
<p className={`${themeClasses.textPrimary} font-medium`}>{user.name}</p>
|
|
468
|
+
<p className={`${themeClasses.textSecondary} text-xs`}>{user.email}</p>
|
|
469
|
+
</div>
|
|
470
|
+
</td>
|
|
471
|
+
<td className="px-4 py-3">
|
|
472
|
+
<span className="px-2 py-1 bg-blue-600/20 text-blue-400 rounded text-xs font-medium">
|
|
473
|
+
{user.count}
|
|
474
|
+
</span>
|
|
475
|
+
</td>
|
|
476
|
+
<td className={`px-4 py-3 ${themeClasses.textSecondary}`}>{formatDate(user.lastLogin)}</td>
|
|
477
|
+
</tr>
|
|
478
|
+
))
|
|
479
|
+
) : (
|
|
480
|
+
<tr>
|
|
481
|
+
<td colSpan={4} className={`px-4 py-8 text-center ${themeClasses.textMuted}`}>
|
|
482
|
+
No login data for this period
|
|
483
|
+
</td>
|
|
484
|
+
</tr>
|
|
485
|
+
)}
|
|
486
|
+
</tbody>
|
|
487
|
+
</table>
|
|
488
|
+
</div>
|
|
489
|
+
</div>
|
|
490
|
+
|
|
491
|
+
{/* Business Metrics Section */}
|
|
492
|
+
{showBusinessMetrics && (
|
|
493
|
+
<div className={`border-t ${isDark ? 'border-slate-700' : 'border-gray-200'} pt-6 mt-6`}>
|
|
494
|
+
<h2 className={`text-xl font-bold ${themeClasses.textPrimary} mb-6`}>Business Metrics</h2>
|
|
495
|
+
|
|
496
|
+
{/* Revenue Summary Cards */}
|
|
497
|
+
{revenueData && (
|
|
498
|
+
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
|
|
499
|
+
<div className={`${themeClasses.cardBg} border rounded-lg p-4`}>
|
|
500
|
+
<p className={themeClasses.textSecondary + ' text-sm'}>Monthly Revenue (MRR)</p>
|
|
501
|
+
<p className="text-3xl font-bold text-green-400">${revenueData.mrr.toLocaleString()}</p>
|
|
502
|
+
</div>
|
|
503
|
+
<div className={`${themeClasses.cardBg} border rounded-lg p-4`}>
|
|
504
|
+
<p className={themeClasses.textSecondary + ' text-sm'}>Annual Revenue (ARR)</p>
|
|
505
|
+
<p className="text-3xl font-bold text-green-400">${revenueData.arr.toLocaleString()}</p>
|
|
506
|
+
</div>
|
|
507
|
+
<div className={`${themeClasses.cardBg} border rounded-lg p-4`}>
|
|
508
|
+
<p className={themeClasses.textSecondary + ' text-sm'}>Paid Subscribers</p>
|
|
509
|
+
<p className="text-3xl font-bold text-blue-400">{revenueData.paidSubscribers.toLocaleString()}</p>
|
|
510
|
+
</div>
|
|
511
|
+
<div className={`${themeClasses.cardBg} border rounded-lg p-4`}>
|
|
512
|
+
<p className={themeClasses.textSecondary + ' text-sm'}>Conversion Rate</p>
|
|
513
|
+
<p className="text-3xl font-bold text-purple-400">{revenueData.conversionRate}%</p>
|
|
514
|
+
</div>
|
|
515
|
+
</div>
|
|
516
|
+
)}
|
|
517
|
+
|
|
518
|
+
<div className="grid md:grid-cols-2 gap-6">
|
|
519
|
+
{/* Tier Distribution - Donut Chart */}
|
|
520
|
+
{tierData && (
|
|
521
|
+
<div className={`${themeClasses.cardBg} border rounded-lg p-6`}>
|
|
522
|
+
<h3 className={`text-lg font-semibold ${themeClasses.textPrimary} mb-4`}>Tier Distribution</h3>
|
|
523
|
+
<div className="flex items-center gap-8">
|
|
524
|
+
{/* Donut Chart */}
|
|
525
|
+
<div className="relative w-40 h-40">
|
|
526
|
+
<svg viewBox="0 0 100 100" className="transform -rotate-90">
|
|
527
|
+
{(() => {
|
|
528
|
+
const colors: Record<string, string> = {
|
|
529
|
+
free: '#64748b',
|
|
530
|
+
premium: '#3b82f6',
|
|
531
|
+
ultimate: '#f59e0b',
|
|
532
|
+
enterprise: '#8b5cf6',
|
|
533
|
+
};
|
|
534
|
+
let offset = 0;
|
|
535
|
+
return tierData.distribution.map((tier) => {
|
|
536
|
+
const strokeDasharray = `${tier.percentage} ${100 - tier.percentage}`;
|
|
537
|
+
const strokeDashoffset = -offset;
|
|
538
|
+
offset += tier.percentage;
|
|
539
|
+
return (
|
|
540
|
+
<circle
|
|
541
|
+
key={tier.tier}
|
|
542
|
+
cx="50"
|
|
543
|
+
cy="50"
|
|
544
|
+
r="40"
|
|
545
|
+
fill="transparent"
|
|
546
|
+
stroke={colors[tier.tier] || '#64748b'}
|
|
547
|
+
strokeWidth="20"
|
|
548
|
+
strokeDasharray={strokeDasharray}
|
|
549
|
+
strokeDashoffset={strokeDashoffset}
|
|
550
|
+
/>
|
|
551
|
+
);
|
|
552
|
+
});
|
|
553
|
+
})()}
|
|
554
|
+
</svg>
|
|
555
|
+
<div className="absolute inset-0 flex items-center justify-center">
|
|
556
|
+
<div className="text-center">
|
|
557
|
+
<p className={`text-2xl font-bold ${themeClasses.textPrimary}`}>{tierData.total}</p>
|
|
558
|
+
<p className={`text-xs ${themeClasses.textSecondary}`}>Total</p>
|
|
559
|
+
</div>
|
|
560
|
+
</div>
|
|
561
|
+
</div>
|
|
562
|
+
{/* Legend */}
|
|
563
|
+
<div className="flex-1 space-y-2">
|
|
564
|
+
{tierData.distribution.map((tier) => {
|
|
565
|
+
const colors: Record<string, string> = {
|
|
566
|
+
free: 'bg-slate-500',
|
|
567
|
+
premium: 'bg-blue-500',
|
|
568
|
+
ultimate: 'bg-amber-500',
|
|
569
|
+
enterprise: 'bg-purple-500',
|
|
570
|
+
};
|
|
571
|
+
return (
|
|
572
|
+
<div key={tier.tier} className="flex items-center justify-between">
|
|
573
|
+
<div className="flex items-center gap-2">
|
|
574
|
+
<div className={`w-3 h-3 rounded-full ${colors[tier.tier] || 'bg-slate-500'}`} />
|
|
575
|
+
<span className={`${themeClasses.textPrimary} capitalize`}>{tier.tier}</span>
|
|
576
|
+
</div>
|
|
577
|
+
<div className="text-right">
|
|
578
|
+
<span className={`${themeClasses.textPrimary} font-medium`}>{tier.count}</span>
|
|
579
|
+
<span className={`${themeClasses.textMuted} text-sm ml-2`}>({tier.percentage}%)</span>
|
|
580
|
+
</div>
|
|
581
|
+
</div>
|
|
582
|
+
);
|
|
583
|
+
})}
|
|
584
|
+
</div>
|
|
585
|
+
</div>
|
|
586
|
+
</div>
|
|
587
|
+
)}
|
|
588
|
+
|
|
589
|
+
{/* Feature Usage Chart */}
|
|
590
|
+
{featureData && (
|
|
591
|
+
<div className={`${themeClasses.cardBg} border rounded-lg p-6`}>
|
|
592
|
+
<h3 className={`text-lg font-semibold ${themeClasses.textPrimary} mb-4`}>Feature Usage</h3>
|
|
593
|
+
<div className="space-y-3">
|
|
594
|
+
{featureData.features.map((feature) => {
|
|
595
|
+
const maxUses = Math.max(...featureData.features.map(f => f.uses), 1);
|
|
596
|
+
const percentage = (feature.uses / maxUses) * 100;
|
|
597
|
+
return (
|
|
598
|
+
<div key={feature.name}>
|
|
599
|
+
<div className="flex justify-between items-center mb-1">
|
|
600
|
+
<span className={`${themeClasses.textPrimary} text-sm`}>{feature.name}</span>
|
|
601
|
+
<span className={`${themeClasses.textSecondary} text-xs`}>{feature.uses.toLocaleString()} uses</span>
|
|
602
|
+
</div>
|
|
603
|
+
<div className={`h-4 ${themeClasses.barBg} rounded overflow-hidden`}>
|
|
604
|
+
<div
|
|
605
|
+
className="h-full bg-gradient-to-r from-indigo-500 to-purple-500 transition-all duration-300"
|
|
606
|
+
style={{ width: `${percentage}%` }}
|
|
607
|
+
/>
|
|
608
|
+
</div>
|
|
609
|
+
</div>
|
|
610
|
+
);
|
|
611
|
+
})}
|
|
612
|
+
</div>
|
|
613
|
+
</div>
|
|
614
|
+
)}
|
|
615
|
+
</div>
|
|
616
|
+
|
|
617
|
+
{/* Revenue by Tier */}
|
|
618
|
+
{revenueData && revenueData.revenueByTier.length > 0 && (
|
|
619
|
+
<div className={`mt-6 ${themeClasses.cardBg} border rounded-lg overflow-hidden`}>
|
|
620
|
+
<div className={`p-4 border-b ${isDark ? 'border-slate-700' : 'border-gray-200'}`}>
|
|
621
|
+
<h3 className={`text-lg font-semibold ${themeClasses.textPrimary}`}>Revenue by Tier</h3>
|
|
622
|
+
</div>
|
|
623
|
+
<div className="overflow-x-auto">
|
|
624
|
+
<table className="w-full text-sm">
|
|
625
|
+
<thead className={isDark ? 'bg-slate-700/50' : 'bg-gray-50'}>
|
|
626
|
+
<tr>
|
|
627
|
+
<th className={`px-4 py-3 text-left ${themeClasses.textSecondary} font-medium`}>Tier</th>
|
|
628
|
+
<th className={`px-4 py-3 text-left ${themeClasses.textSecondary} font-medium`}>Subscribers</th>
|
|
629
|
+
<th className={`px-4 py-3 text-left ${themeClasses.textSecondary} font-medium`}>Price/Mo</th>
|
|
630
|
+
<th className={`px-4 py-3 text-left ${themeClasses.textSecondary} font-medium`}>MRR</th>
|
|
631
|
+
<th className={`px-4 py-3 text-left ${themeClasses.textSecondary} font-medium`}>% of Revenue</th>
|
|
632
|
+
</tr>
|
|
633
|
+
</thead>
|
|
634
|
+
<tbody className={`divide-y ${isDark ? 'divide-slate-700' : 'divide-gray-200'}`}>
|
|
635
|
+
{revenueData.revenueByTier.map((tier) => {
|
|
636
|
+
const totalMrr = revenueData.revenueByTier.reduce((sum, t) => sum + t.mrr, 0);
|
|
637
|
+
const percentage = totalMrr > 0 ? Math.round((tier.mrr / totalMrr) * 100) : 0;
|
|
638
|
+
const tierColors: Record<string, string> = {
|
|
639
|
+
premium: 'text-blue-400',
|
|
640
|
+
ultimate: 'text-amber-400',
|
|
641
|
+
enterprise: 'text-purple-400',
|
|
642
|
+
};
|
|
643
|
+
return (
|
|
644
|
+
<tr key={tier.tier} className={isDark ? 'hover:bg-slate-700/50' : 'hover:bg-gray-50'}>
|
|
645
|
+
<td className={`px-4 py-3 font-medium capitalize ${tierColors[tier.tier] || themeClasses.textPrimary}`}>
|
|
646
|
+
{tier.tier}
|
|
647
|
+
</td>
|
|
648
|
+
<td className={`px-4 py-3 ${themeClasses.textPrimary}`}>{tier.subscribers.toLocaleString()}</td>
|
|
649
|
+
<td className={`px-4 py-3 ${themeClasses.textSecondary}`}>${tier.pricePerMonth}</td>
|
|
650
|
+
<td className="px-4 py-3 text-green-400 font-medium">${tier.mrr.toLocaleString()}</td>
|
|
651
|
+
<td className="px-4 py-3">
|
|
652
|
+
<div className="flex items-center gap-2">
|
|
653
|
+
<div className={`w-16 h-2 ${themeClasses.barBg} rounded overflow-hidden`}>
|
|
654
|
+
<div className="h-full bg-green-500" style={{ width: `${percentage}%` }} />
|
|
655
|
+
</div>
|
|
656
|
+
<span className={`${themeClasses.textSecondary} text-xs`}>{percentage}%</span>
|
|
657
|
+
</div>
|
|
658
|
+
</td>
|
|
659
|
+
</tr>
|
|
660
|
+
);
|
|
661
|
+
})}
|
|
662
|
+
</tbody>
|
|
663
|
+
</table>
|
|
664
|
+
</div>
|
|
665
|
+
</div>
|
|
666
|
+
)}
|
|
667
|
+
|
|
668
|
+
{/* MRR Trend Chart */}
|
|
669
|
+
{revenueData && revenueData.trend.length > 0 && (
|
|
670
|
+
<div className={`mt-6 ${themeClasses.cardBg} border rounded-lg p-6`}>
|
|
671
|
+
<h3 className={`text-lg font-semibold ${themeClasses.textPrimary} mb-4`}>MRR Trend (6 Months)</h3>
|
|
672
|
+
<div className="h-48 flex items-end gap-2">
|
|
673
|
+
{revenueData.trend.map((point, i) => {
|
|
674
|
+
const maxMrr = Math.max(...revenueData.trend.map(p => p.mrr), 1);
|
|
675
|
+
const height = (point.mrr / maxMrr) * 100;
|
|
676
|
+
return (
|
|
677
|
+
<div key={i} className="flex-1 flex flex-col items-center group">
|
|
678
|
+
<div className="relative w-full">
|
|
679
|
+
<div
|
|
680
|
+
className="w-full bg-gradient-to-t from-green-600 to-green-400 rounded-t transition-all duration-300 hover:from-green-500 hover:to-green-300"
|
|
681
|
+
style={{ height: `${height * 1.8}px`, minHeight: '4px' }}
|
|
682
|
+
/>
|
|
683
|
+
{/* Tooltip */}
|
|
684
|
+
<div className="absolute bottom-full mb-2 left-1/2 -translate-x-1/2 hidden group-hover:block z-10">
|
|
685
|
+
<div className={`${isDark ? 'bg-slate-900' : 'bg-gray-800'} text-white text-xs px-2 py-1 rounded shadow-lg whitespace-nowrap`}>
|
|
686
|
+
${point.mrr.toLocaleString()}
|
|
687
|
+
</div>
|
|
688
|
+
</div>
|
|
689
|
+
</div>
|
|
690
|
+
<span className={`text-xs ${themeClasses.textMuted} mt-2`}>{point.month}</span>
|
|
691
|
+
</div>
|
|
692
|
+
);
|
|
693
|
+
})}
|
|
694
|
+
</div>
|
|
695
|
+
</div>
|
|
696
|
+
)}
|
|
697
|
+
</div>
|
|
698
|
+
)}
|
|
699
|
+
</div>
|
|
700
|
+
);
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
export default AnalyticsTab;
|