@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.
Files changed (161) hide show
  1. package/README.md +792 -792
  2. package/dist/api-handlers/auth/refresh.js +38 -15
  3. package/dist/auth/better-auth.js +7 -18
  4. package/dist/components/account/MobileNavDrawer.js +18 -18
  5. package/dist/components/account/UserAvatarMenu.js +1 -1
  6. package/dist/components/admin/AlertSettingsTab.js +40 -40
  7. package/dist/components/admin/LoggingSettingsTab.js +28 -28
  8. package/dist/lib/ensure-fresh-access-token.js +30 -7
  9. package/dist/lib/idp-fetch.js +25 -9
  10. package/dist/lib/session-store.d.ts +1 -0
  11. package/dist/lib/session-store.js +12 -8
  12. package/dist/pages/login/page.js +28 -28
  13. package/dist/pages/settings/EnhancedSettingsPage.js +17 -17
  14. package/package.json +893 -893
  15. package/scripts/dev-broker.ps1 +35 -35
  16. package/scripts/dev-local.ps1 +45 -45
  17. package/src/api/auth-handler.ts +548 -548
  18. package/src/api-handlers/account/change-password.ts +142 -142
  19. package/src/api-handlers/account/update-phone.ts +79 -79
  20. package/src/api-handlers/account/verify-email.ts +125 -125
  21. package/src/api-handlers/account/verify-sms.ts +125 -125
  22. package/src/api-handlers/admin/analytics.ts +443 -443
  23. package/src/api-handlers/admin/audit.ts +223 -223
  24. package/src/api-handlers/admin/index.ts +63 -63
  25. package/src/api-handlers/admin/redis-sessions.ts +251 -251
  26. package/src/api-handlers/admin/sessions.ts +318 -318
  27. package/src/api-handlers/admin/site-logs.ts +365 -365
  28. package/src/api-handlers/admin/users.ts +242 -242
  29. package/src/api-handlers/admin/vibe-data.ts +324 -324
  30. package/src/api-handlers/auth/refresh.ts +705 -682
  31. package/src/api-handlers/auth/signout.ts +199 -199
  32. package/src/api-handlers/auth/status.ts +20 -20
  33. package/src/api-handlers/auth/update-session.ts +110 -110
  34. package/src/api-handlers/auth/verify-code.ts +112 -112
  35. package/src/api-handlers/session/refresh-viability.ts +36 -36
  36. package/src/api-handlers/test/force-expire.ts +60 -60
  37. package/src/auth/auth-decision.ts +229 -229
  38. package/src/auth/better-auth.ts +7 -18
  39. package/src/auth/route-config.ts +219 -219
  40. package/src/auth/types/auth-types.ts +555 -555
  41. package/src/auth/types/index.ts +7 -7
  42. package/src/auth/utils/idp-client.ts +445 -445
  43. package/src/auth/utils/index.ts +6 -6
  44. package/src/auth/utils/token-utils.ts +243 -243
  45. package/src/client/AuthContext.tsx +143 -143
  46. package/src/client/fetch-with-auth.ts +47 -47
  47. package/src/components/SessionSync.tsx +138 -138
  48. package/src/components/SignalRHealthCheck.tsx +131 -131
  49. package/src/components/account/MobileNavDrawer.tsx +305 -305
  50. package/src/components/account/UserAvatarMenu.tsx +266 -266
  51. package/src/components/account/index.ts +13 -13
  52. package/src/components/admin/AlertSettingsTab.tsx +728 -728
  53. package/src/components/admin/AnalyticsTab.tsx +703 -703
  54. package/src/components/admin/DataBrowserTab.tsx +505 -505
  55. package/src/components/admin/LoggingSettingsTab.tsx +665 -665
  56. package/src/components/admin/SessionsTab.tsx +414 -414
  57. package/src/components/admin/StatsTab.tsx +379 -379
  58. package/src/components/admin/VibeAdminLayout.tsx +187 -187
  59. package/src/components/admin/index.ts +59 -59
  60. package/src/components/auth/FederatedAuthSection.tsx +95 -95
  61. package/src/components/auth/ModeAwareLoginPage.tsx +135 -135
  62. package/src/components/auth/ModeAwareSignupPage.tsx +267 -267
  63. package/src/components/auth/TraditionalAuthSection.tsx +99 -99
  64. package/src/components/recovery/CompleteStep.tsx +36 -36
  65. package/src/components/recovery/InitiateRecoveryStep.tsx +68 -68
  66. package/src/components/recovery/SelectMethodStep.tsx +73 -73
  67. package/src/components/recovery/SetPasswordStep.tsx +97 -97
  68. package/src/components/recovery/VerifyCodeStep.tsx +90 -90
  69. package/src/components/reserved/ReservedRecoveryWarning.tsx +160 -160
  70. package/src/components/reserved/ReservedStatusBox.tsx +118 -118
  71. package/src/components/ui/BetaBadge.tsx +58 -58
  72. package/src/components/ui/Footer.tsx +93 -93
  73. package/src/config/env.ts +57 -57
  74. package/src/config/logger.ts +62 -62
  75. package/src/config/vibe-log-transport.ts +264 -264
  76. package/src/hooks/useAuth.ts +117 -117
  77. package/src/hooks/useAuthSettings.ts +97 -97
  78. package/src/hooks/useAvailableProviders.ts +120 -120
  79. package/src/hooks/usePasswordValidation.ts +127 -127
  80. package/src/hooks/usePublicAuthSettings.ts +149 -149
  81. package/src/hooks/useSessionExpiration.ts +101 -101
  82. package/src/hooks/useViabilitySession.ts +336 -336
  83. package/src/index.ts +60 -60
  84. package/src/lib/api-handler.ts +643 -643
  85. package/src/lib/app-slug.ts +178 -178
  86. package/src/lib/ensure-fresh-access-token.ts +358 -335
  87. package/src/lib/geolocation.ts +265 -265
  88. package/src/lib/idp-fetch.ts +26 -10
  89. package/src/lib/jwt-decode.ts +82 -82
  90. package/src/lib/roles.ts +177 -177
  91. package/src/lib/session-store.ts +4 -1
  92. package/src/lib/site-logger.ts +245 -245
  93. package/src/lib/standardized-client-api.ts +901 -901
  94. package/src/lib/test-aware-get-token.ts +41 -41
  95. package/src/lib/user-agent-parser.ts +252 -252
  96. package/src/logging/api/admin-analytics.ts +51 -51
  97. package/src/logging/api/audit-log.ts +53 -53
  98. package/src/logging/components/AdminAnalyticsLayout.tsx +49 -49
  99. package/src/logging/components/AuditLogViewer.tsx +125 -125
  100. package/src/logging/components/ErrorMetricsCard.tsx +98 -98
  101. package/src/logging/components/HealthMetricsCard.tsx +70 -70
  102. package/src/logging/hooks/useAdminAnalytics.ts +22 -22
  103. package/src/logging/hooks/useAuditLog.ts +24 -24
  104. package/src/logging/hooks/useErrorMetrics.ts +40 -40
  105. package/src/logging/hooks/useHealthMetrics.ts +44 -44
  106. package/src/logging/index.ts +18 -18
  107. package/src/logging/types/analytics.ts +81 -81
  108. package/src/logging/types/audit.ts +31 -31
  109. package/src/logging/types/index.ts +3 -3
  110. package/src/middleware/create-middleware.ts +663 -663
  111. package/src/middleware/rbac-check.ts +279 -279
  112. package/src/middleware/twofa-presets.ts +224 -224
  113. package/src/pages/admin-login/page.tsx +221 -221
  114. package/src/pages/admin-page-permissions/PagePermissionsAdminPage.tsx +527 -527
  115. package/src/pages/admin-page-permissions/index.ts +7 -7
  116. package/src/pages/admin-roles/RolesAdminPage.tsx +533 -533
  117. package/src/pages/admin-roles/modals.tsx +469 -469
  118. package/src/pages/client-admin/ClientSiteAdminPage.tsx +382 -382
  119. package/src/pages/client-admin/index.ts +33 -33
  120. package/src/pages/coming-soon/page.tsx +83 -83
  121. package/src/pages/login/page.tsx +456 -456
  122. package/src/pages/profile/EnhancedProfilePage.tsx +485 -485
  123. package/src/pages/profile/index.ts +9 -9
  124. package/src/pages/profile/page.tsx +166 -166
  125. package/src/pages/recovery/page.tsx +234 -234
  126. package/src/pages/roles/MyRolesPage.tsx +211 -211
  127. package/src/pages/roles/components.tsx +294 -294
  128. package/src/pages/roles/index.ts +17 -17
  129. package/src/pages/security/EnhancedSecurityPage.tsx +565 -565
  130. package/src/pages/security/index.ts +9 -9
  131. package/src/pages/security/page.tsx +507 -507
  132. package/src/pages/settings/EnhancedSettingsPage.tsx +642 -642
  133. package/src/pages/settings/index.ts +9 -9
  134. package/src/pages/settings/page.tsx +47 -47
  135. package/src/pages/showcase/ShowcasePage.tsx +532 -532
  136. package/src/pages/showcase/index.ts +13 -13
  137. package/src/pages/test-env/EmergencyLogoutPage.tsx +180 -180
  138. package/src/pages/test-env/JwtInspectPage.tsx +456 -456
  139. package/src/pages/test-env/RefreshTokenPage.tsx +157 -157
  140. package/src/pages/test-env/TestEnvPage.tsx +118 -118
  141. package/src/pages/test-env/index.ts +25 -25
  142. package/src/pages/verify-code/page.tsx +652 -652
  143. package/src/routes/account/update-phone.ts +13 -13
  144. package/src/routes/auth/logout.ts +108 -108
  145. package/src/routes/auth/nextauth.ts +45 -45
  146. package/src/routes/auth/settings.ts +75 -75
  147. package/src/routes/auth/viability.ts +224 -224
  148. package/src/server/auth-guard.ts +170 -170
  149. package/src/server/slim-middleware.ts +117 -117
  150. package/src/server/with-auth.ts +93 -93
  151. package/src/stores/authStore.ts +1899 -1899
  152. package/src/templates/instrumentation.ts +41 -41
  153. package/src/utils/logout.ts +30 -30
  154. package/src/vibe/client.ts +590 -590
  155. package/src/vibe/enterprise-auth.ts +208 -208
  156. package/src/vibe/errors.ts +185 -185
  157. package/src/vibe/generic.ts +431 -431
  158. package/src/vibe/hooks/index.ts +367 -367
  159. package/src/vibe/index.ts +132 -132
  160. package/src/vibe/sessions.ts +551 -551
  161. 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;