ultimate-jekyll-manager 1.0.12 → 1.0.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +18 -0
- package/dist/assets/js/libs/admin-helpers.js +14 -0
- package/dist/assets/js/pages/admin/calendar/index.js +0 -12
- package/dist/assets/js/pages/admin/dashboard/index.js +74 -55
- package/dist/assets/js/pages/admin/firebase/index.js +0 -9
- package/dist/assets/js/pages/admin/users/index.js +6 -15
- package/dist/defaults/dist/_layouts/blueprint/admin/dashboard/index.html +12 -24
- package/dist/defaults/dist/_layouts/blueprint/admin/users/index.html +7 -6
- package/dist/defaults/dist/pages/test/libraries/firestore.html +140 -138
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -15,6 +15,24 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
|
|
15
15
|
- `Security` in case of vulnerabilities.
|
|
16
16
|
|
|
17
17
|
---
|
|
18
|
+
## [1.0.13] - 2026-03-27
|
|
19
|
+
### Added
|
|
20
|
+
- MRR stat card on admin dashboard calculated from brand config prices × subscriber counts
|
|
21
|
+
- `setStatSubValue` helper in admin-helpers.js for displaying sub-metrics on stat cards
|
|
22
|
+
- Green "+N in 30d" sub-values under Total Users and Push Subscribers stat cards
|
|
23
|
+
- New "Active users (30d)" stat card on admin users page
|
|
24
|
+
|
|
25
|
+
### Changed
|
|
26
|
+
- Dashboard charts now use `getCountFromServer` queries per product × frequency instead of fetching all user docs
|
|
27
|
+
- Product list and billing frequencies derived dynamically from `/backend-manager/brand` API
|
|
28
|
+
- Consolidated "New users (30d)" from standalone card into sub-value under Total Users
|
|
29
|
+
|
|
30
|
+
### Fixed
|
|
31
|
+
- Pacman-shaped spinners in stat cards caused by `spinner-border-sm` inheriting `<h3>` font size (added `fs-6`)
|
|
32
|
+
|
|
33
|
+
### Removed
|
|
34
|
+
- `showUnauthenticated()` flows from all admin pages — pages now return early if no user
|
|
35
|
+
|
|
18
36
|
## [1.0.11] - 2026-03-24
|
|
19
37
|
### Added
|
|
20
38
|
- Firestore version + transport test page at `/test/libraries/firestore` for diagnosing SDK connectivity across browsers
|
|
@@ -52,3 +52,17 @@ export function setStatValue(id, result) {
|
|
|
52
52
|
console.error(`Failed to load ${id}:`, result.reason);
|
|
53
53
|
}
|
|
54
54
|
}
|
|
55
|
+
|
|
56
|
+
export function setStatSubValue(id, result, label) {
|
|
57
|
+
const $el = document.getElementById(id);
|
|
58
|
+
if (!$el) {
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (result.status === 'fulfilled') {
|
|
63
|
+
const count = result.value.data().count;
|
|
64
|
+
$el.textContent = `+${count.toLocaleString()} ${label}`;
|
|
65
|
+
$el.classList.add('text-success');
|
|
66
|
+
$el.classList.remove('text-muted');
|
|
67
|
+
}
|
|
68
|
+
}
|
|
@@ -21,7 +21,6 @@ export default (Manager) => {
|
|
|
21
21
|
|
|
22
22
|
webManager.auth().listen({ once: true }, async (state) => {
|
|
23
23
|
if (!state.user) {
|
|
24
|
-
showUnauthenticated();
|
|
25
24
|
return;
|
|
26
25
|
}
|
|
27
26
|
|
|
@@ -44,14 +43,3 @@ function initialize() {
|
|
|
44
43
|
core.initialize();
|
|
45
44
|
}
|
|
46
45
|
|
|
47
|
-
// Show unauthenticated state
|
|
48
|
-
function showUnauthenticated() {
|
|
49
|
-
const $grid = document.getElementById('calendar-grid');
|
|
50
|
-
$grid.innerHTML = `
|
|
51
|
-
<div class="d-flex align-items-center justify-content-center h-100 text-muted">
|
|
52
|
-
<div class="text-center">
|
|
53
|
-
<p>Sign in to view the marketing calendar</p>
|
|
54
|
-
</div>
|
|
55
|
-
</div>
|
|
56
|
-
`;
|
|
57
|
-
}
|
|
@@ -4,8 +4,9 @@
|
|
|
4
4
|
|
|
5
5
|
// Libraries
|
|
6
6
|
import { getPrerenderedIcon } from '__main_assets__/js/libs/prerendered-icons.js';
|
|
7
|
+
import fetch from 'wonderful-fetch';
|
|
7
8
|
import authorizedFetch from '__main_assets__/js/libs/authorized-fetch.js';
|
|
8
|
-
import { formatTimeAgo, capitalize, escapeHtml, setStatValue } from '__main_assets__/js/libs/admin-helpers.js';
|
|
9
|
+
import { formatTimeAgo, capitalize, escapeHtml, setStatValue, setStatSubValue } from '__main_assets__/js/libs/admin-helpers.js';
|
|
9
10
|
import { Chart, DoughnutController, BarController, ArcElement, BarElement, CategoryScale, LinearScale, Tooltip, Legend } from 'chart.js';
|
|
10
11
|
|
|
11
12
|
// Register Chart.js components
|
|
@@ -25,7 +26,6 @@ export default (Manager) => {
|
|
|
25
26
|
|
|
26
27
|
webManager.auth().listen({ once: true }, async (state) => {
|
|
27
28
|
if (!state.user) {
|
|
28
|
-
showUnauthenticated();
|
|
29
29
|
return;
|
|
30
30
|
}
|
|
31
31
|
|
|
@@ -37,18 +37,6 @@ export default (Manager) => {
|
|
|
37
37
|
});
|
|
38
38
|
};
|
|
39
39
|
|
|
40
|
-
// Show unauthenticated state
|
|
41
|
-
function showUnauthenticated() {
|
|
42
|
-
// Replace all spinners with sign-in message
|
|
43
|
-
document.querySelectorAll('.spinner-border').forEach((spinner) => {
|
|
44
|
-
const container = spinner.closest('.card-body') || spinner.parentElement;
|
|
45
|
-
spinner.replaceWith(Object.assign(document.createElement('span'), {
|
|
46
|
-
className: 'text-muted small',
|
|
47
|
-
textContent: 'Sign in to view',
|
|
48
|
-
}));
|
|
49
|
-
});
|
|
50
|
-
}
|
|
51
|
-
|
|
52
40
|
// Load all dashboard data in parallel
|
|
53
41
|
async function loadDashboard() {
|
|
54
42
|
const results = await Promise.allSettled([
|
|
@@ -74,54 +62,97 @@ async function loadStatCards() {
|
|
|
74
62
|
const now = Math.floor(Date.now() / 1000);
|
|
75
63
|
const thirtyDaysAgo = now - (30 * 24 * 60 * 60);
|
|
76
64
|
|
|
77
|
-
const [totalUsers, newUsers,
|
|
65
|
+
const [totalUsers, newUsers, totalNotifications, newNotifications] = await Promise.allSettled([
|
|
78
66
|
getCountFromServer(collection(db, 'users')),
|
|
79
67
|
getCountFromServer(query(collection(db, 'users'), where('metadata.created.timestampUNIX', '>=', thirtyDaysAgo))),
|
|
80
|
-
getCountFromServer(query(collection(db, 'users'), where('subscription.status', '==', 'active'), where('subscription.product.id', '!=', 'basic'))),
|
|
81
68
|
getCountFromServer(collection(db, 'notifications')),
|
|
69
|
+
getCountFromServer(query(collection(db, 'notifications'), where('metadata.created.timestampUNIX', '>=', thirtyDaysAgo))),
|
|
82
70
|
]);
|
|
83
71
|
|
|
84
72
|
setStatValue('stat-total-users', totalUsers);
|
|
85
|
-
|
|
86
|
-
setStatValue('stat-
|
|
87
|
-
|
|
73
|
+
setStatSubValue('stat-new-users', newUsers, 'in 30d');
|
|
74
|
+
setStatValue('stat-notifications', totalNotifications);
|
|
75
|
+
setStatSubValue('stat-new-notifications', newNotifications, 'in 30d');
|
|
88
76
|
}
|
|
89
77
|
|
|
90
78
|
// ============================================
|
|
91
79
|
// Subscriber Data (for charts)
|
|
92
80
|
// ============================================
|
|
93
81
|
async function loadSubscriberData() {
|
|
94
|
-
const
|
|
82
|
+
const { collection, query, where, getCountFromServer } = await import('firebase/firestore');
|
|
83
|
+
const db = webManager.firebaseFirestore;
|
|
95
84
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
85
|
+
// Fetch brand config to get product list and available frequencies
|
|
86
|
+
const brandConfig = await fetch(`${webManager.getApiUrl()}/backend-manager/brand`, {
|
|
87
|
+
response: 'json',
|
|
88
|
+
tries: 2,
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
const products = (brandConfig?.payment?.products || []).filter((p) => p.id !== 'basic');
|
|
92
|
+
const frequencyIds = [...new Set(products.flatMap((p) => Object.keys(p.prices || {})))];
|
|
99
93
|
|
|
100
|
-
//
|
|
94
|
+
// Run count queries for each product × frequency in parallel
|
|
95
|
+
const countQueries = products.flatMap((product) =>
|
|
96
|
+
frequencyIds.map((freq) =>
|
|
97
|
+
getCountFromServer(query(
|
|
98
|
+
collection(db, 'users'),
|
|
99
|
+
where('subscription.status', '==', 'active'),
|
|
100
|
+
where('subscription.product.id', '==', product.id),
|
|
101
|
+
where('subscription.payment.frequency', '==', freq),
|
|
102
|
+
)).then((snap) => ({ planId: product.id, frequency: freq, count: snap.data().count }))
|
|
103
|
+
)
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
const results = await Promise.all(countQueries);
|
|
107
|
+
|
|
108
|
+
// Build chart data from counts
|
|
101
109
|
const plans = {};
|
|
102
110
|
const frequencies = {};
|
|
103
111
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
112
|
+
results.forEach(({ planId, frequency, count }) => {
|
|
113
|
+
if (count === 0) {
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
108
116
|
|
|
109
|
-
|
|
110
|
-
plans[planId] = (plans[planId] || 0) + 1;
|
|
117
|
+
plans[planId] = (plans[planId] || 0) + count;
|
|
111
118
|
|
|
112
|
-
// Frequency per plan
|
|
113
119
|
if (!frequencies[planId]) {
|
|
114
|
-
frequencies[planId] =
|
|
120
|
+
frequencies[planId] = Object.fromEntries(frequencyIds.map((f) => [f, 0]));
|
|
115
121
|
}
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
122
|
+
frequencies[planId][frequency] = count;
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
// Calculate MRR from counts × product prices
|
|
126
|
+
const MONTHS_PER_FREQUENCY = { daily: 1 / 30, weekly: 1 / 4, monthly: 1, annually: 12 };
|
|
127
|
+
let mrr = 0;
|
|
128
|
+
let totalSubscribers = 0;
|
|
129
|
+
|
|
130
|
+
results.forEach(({ planId, frequency, count }) => {
|
|
131
|
+
if (count === 0) {
|
|
132
|
+
return;
|
|
120
133
|
}
|
|
134
|
+
|
|
135
|
+
const product = products.find((p) => p.id === planId);
|
|
136
|
+
const priceEntry = product?.prices?.[frequency];
|
|
137
|
+
const price = typeof priceEntry === 'object' ? (priceEntry?.amount || 0) : Number(priceEntry) || 0;
|
|
138
|
+
const months = MONTHS_PER_FREQUENCY[frequency] || 1;
|
|
139
|
+
|
|
140
|
+
mrr += (price / months) * count;
|
|
141
|
+
totalSubscribers += count;
|
|
121
142
|
});
|
|
122
143
|
|
|
144
|
+
// Set MRR stat card
|
|
145
|
+
const $mrr = document.getElementById('stat-mrr');
|
|
146
|
+
if ($mrr) {
|
|
147
|
+
$mrr.textContent = `$${Math.round(mrr).toLocaleString()}`;
|
|
148
|
+
}
|
|
149
|
+
const $mrrCount = document.getElementById('stat-mrr-count');
|
|
150
|
+
if ($mrrCount) {
|
|
151
|
+
$mrrCount.textContent = `${totalSubscribers.toLocaleString()} subscriber${totalSubscribers === 1 ? '' : 's'}`;
|
|
152
|
+
}
|
|
153
|
+
|
|
123
154
|
renderPlanChart(plans);
|
|
124
|
-
renderFrequencyChart(frequencies);
|
|
155
|
+
renderFrequencyChart(frequencies, frequencyIds);
|
|
125
156
|
}
|
|
126
157
|
|
|
127
158
|
// ============================================
|
|
@@ -202,7 +233,7 @@ function renderPlanChart(plans) {
|
|
|
202
233
|
});
|
|
203
234
|
}
|
|
204
235
|
|
|
205
|
-
function renderFrequencyChart(frequencies) {
|
|
236
|
+
function renderFrequencyChart(frequencies, frequencyIds) {
|
|
206
237
|
const $loading = document.getElementById('chart-frequency-loading');
|
|
207
238
|
const $canvas = document.getElementById('chart-frequency');
|
|
208
239
|
if (!$canvas) {
|
|
@@ -229,23 +260,11 @@ function renderFrequencyChart(frequencies) {
|
|
|
229
260
|
type: 'bar',
|
|
230
261
|
data: {
|
|
231
262
|
labels: planIds.map(capitalize),
|
|
232
|
-
datasets:
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
},
|
|
238
|
-
{
|
|
239
|
-
label: 'Annually',
|
|
240
|
-
data: planIds.map((id) => frequencies[id].annually),
|
|
241
|
-
backgroundColor: colors.palette[1],
|
|
242
|
-
},
|
|
243
|
-
{
|
|
244
|
-
label: 'Other',
|
|
245
|
-
data: planIds.map((id) => frequencies[id].other),
|
|
246
|
-
backgroundColor: colors.palette[3],
|
|
247
|
-
},
|
|
248
|
-
],
|
|
263
|
+
datasets: frequencyIds.map((freq, i) => ({
|
|
264
|
+
label: capitalize(freq),
|
|
265
|
+
data: planIds.map((id) => frequencies[id]?.[freq] || 0),
|
|
266
|
+
backgroundColor: colors.palette[i % colors.palette.length],
|
|
267
|
+
})),
|
|
249
268
|
},
|
|
250
269
|
options: {
|
|
251
270
|
responsive: true,
|
|
@@ -29,7 +29,6 @@ export default (Manager) => {
|
|
|
29
29
|
|
|
30
30
|
webManager.auth().listen({ once: true }, async (state) => {
|
|
31
31
|
if (!state.user) {
|
|
32
|
-
showUnauthenticated();
|
|
33
32
|
return;
|
|
34
33
|
}
|
|
35
34
|
|
|
@@ -40,14 +39,6 @@ export default (Manager) => {
|
|
|
40
39
|
});
|
|
41
40
|
};
|
|
42
41
|
|
|
43
|
-
// Show unauthenticated state
|
|
44
|
-
function showUnauthenticated() {
|
|
45
|
-
const $empty = document.getElementById('docs-empty');
|
|
46
|
-
if ($empty) {
|
|
47
|
-
$empty.textContent = 'Sign in to view';
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
|
-
|
|
51
42
|
// ============================================
|
|
52
43
|
// Initialize
|
|
53
44
|
// ============================================
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// Libraries
|
|
6
6
|
import { FormManager } from '__main_assets__/js/libs/form-manager.js';
|
|
7
7
|
import authorizedFetch from '__main_assets__/js/libs/authorized-fetch.js';
|
|
8
|
-
import { formatTimeAgo, capitalize, escapeHtml, setStatValue } from '__main_assets__/js/libs/admin-helpers.js';
|
|
8
|
+
import { formatTimeAgo, capitalize, escapeHtml, setStatValue, setStatSubValue } from '__main_assets__/js/libs/admin-helpers.js';
|
|
9
9
|
import { getPrerenderedIcon } from '__main_assets__/js/libs/prerendered-icons.js';
|
|
10
10
|
|
|
11
11
|
// State
|
|
@@ -26,7 +26,6 @@ export default (Manager) => {
|
|
|
26
26
|
|
|
27
27
|
webManager.auth().listen({ once: true }, async (state) => {
|
|
28
28
|
if (!state.user) {
|
|
29
|
-
showUnauthenticated();
|
|
30
29
|
return;
|
|
31
30
|
}
|
|
32
31
|
|
|
@@ -38,16 +37,6 @@ export default (Manager) => {
|
|
|
38
37
|
});
|
|
39
38
|
};
|
|
40
39
|
|
|
41
|
-
// Show unauthenticated state
|
|
42
|
-
function showUnauthenticated() {
|
|
43
|
-
document.querySelectorAll('.spinner-border').forEach((spinner) => {
|
|
44
|
-
spinner.replaceWith(Object.assign(document.createElement('span'), {
|
|
45
|
-
className: 'text-muted small',
|
|
46
|
-
textContent: 'Sign in to view',
|
|
47
|
-
}));
|
|
48
|
-
});
|
|
49
|
-
}
|
|
50
|
-
|
|
51
40
|
// Initialize FormManager for search
|
|
52
41
|
function initForm() {
|
|
53
42
|
formManager = new FormManager('#user-search-form', {
|
|
@@ -73,15 +62,17 @@ async function loadStatCards() {
|
|
|
73
62
|
const now = Math.floor(Date.now() / 1000);
|
|
74
63
|
const thirtyDaysAgo = now - (30 * 24 * 60 * 60);
|
|
75
64
|
|
|
76
|
-
const [totalUsers, activeSubs,
|
|
65
|
+
const [totalUsers, newUsers, activeSubs, activeUsers] = await Promise.allSettled([
|
|
77
66
|
getCountFromServer(collection(db, 'users')),
|
|
78
|
-
getCountFromServer(query(collection(db, 'users'), where('
|
|
67
|
+
getCountFromServer(query(collection(db, 'users'), where('metadata.created.timestampUNIX', '>=', thirtyDaysAgo))),
|
|
68
|
+
getCountFromServer(query(collection(db, 'users'), where('subscription.status', '==', 'active'), where('subscription.product.id', '!=', 'basic'))),
|
|
79
69
|
getCountFromServer(query(collection(db, 'users'), where('metadata.updated.timestampUNIX', '>=', thirtyDaysAgo))),
|
|
80
70
|
]);
|
|
81
71
|
|
|
82
72
|
setStatValue('stat-total-users', totalUsers);
|
|
73
|
+
setStatSubValue('stat-new-users', newUsers, 'in 30d');
|
|
83
74
|
setStatValue('stat-active-subs', activeSubs);
|
|
84
|
-
setStatValue('stat-
|
|
75
|
+
setStatValue('stat-active-users', activeUsers);
|
|
85
76
|
}
|
|
86
77
|
|
|
87
78
|
// Search users by email prefix or UID prefix
|
|
@@ -32,7 +32,7 @@ prerender_icons:
|
|
|
32
32
|
<!-- Stats Cards -->
|
|
33
33
|
<div class="row g-3 mb-4">
|
|
34
34
|
<!-- Total Users -->
|
|
35
|
-
<div class="col-lg-
|
|
35
|
+
<div class="col-lg-4 col-md-6">
|
|
36
36
|
<div class="card h-100">
|
|
37
37
|
<div class="card-body text-center">
|
|
38
38
|
<div class="rounded-circle d-inline-flex align-items-center justify-content-center p-3 bg-primary bg-opacity-10 mx-auto mb-2">
|
|
@@ -40,44 +40,31 @@ prerender_icons:
|
|
|
40
40
|
</div>
|
|
41
41
|
<h6 class="text-muted mb-1">Total users</h6>
|
|
42
42
|
<h3 class="mb-0" id="stat-total-users">
|
|
43
|
-
<span class="spinner-border spinner-border-sm"></span>
|
|
44
|
-
</h3>
|
|
45
|
-
</div>
|
|
46
|
-
</div>
|
|
47
|
-
</div>
|
|
48
|
-
|
|
49
|
-
<!-- New Users (30d) -->
|
|
50
|
-
<div class="col-lg-3 col-md-6">
|
|
51
|
-
<div class="card h-100">
|
|
52
|
-
<div class="card-body text-center">
|
|
53
|
-
<div class="rounded-circle d-inline-flex align-items-center justify-content-center p-3 bg-success bg-opacity-10 mx-auto mb-2">
|
|
54
|
-
{% uj_icon "user-plus", "fa-xl text-success" %}
|
|
55
|
-
</div>
|
|
56
|
-
<h6 class="text-muted mb-1">New users (30d)</h6>
|
|
57
|
-
<h3 class="mb-0" id="stat-new-users">
|
|
58
|
-
<span class="spinner-border spinner-border-sm"></span>
|
|
43
|
+
<span class="spinner-border spinner-border-sm fs-6"></span>
|
|
59
44
|
</h3>
|
|
45
|
+
<small class="text-muted" id="stat-new-users"></small>
|
|
60
46
|
</div>
|
|
61
47
|
</div>
|
|
62
48
|
</div>
|
|
63
49
|
|
|
64
|
-
<!--
|
|
65
|
-
<div class="col-lg-
|
|
50
|
+
<!-- MRR -->
|
|
51
|
+
<div class="col-lg-4 col-md-6">
|
|
66
52
|
<div class="card h-100">
|
|
67
53
|
<div class="card-body text-center">
|
|
68
54
|
<div class="rounded-circle d-inline-flex align-items-center justify-content-center p-3 bg-info bg-opacity-10 mx-auto mb-2">
|
|
69
55
|
{% uj_icon "credit-card", "fa-xl text-info" %}
|
|
70
56
|
</div>
|
|
71
|
-
<h6 class="text-muted mb-1">
|
|
72
|
-
<h3 class="mb-0" id="stat-
|
|
73
|
-
<span class="spinner-border spinner-border-sm"></span>
|
|
57
|
+
<h6 class="text-muted mb-1">MRR</h6>
|
|
58
|
+
<h3 class="mb-0" id="stat-mrr">
|
|
59
|
+
<span class="spinner-border spinner-border-sm fs-6"></span>
|
|
74
60
|
</h3>
|
|
61
|
+
<small class="text-muted" id="stat-mrr-count"></small>
|
|
75
62
|
</div>
|
|
76
63
|
</div>
|
|
77
64
|
</div>
|
|
78
65
|
|
|
79
66
|
<!-- Push Subscribers -->
|
|
80
|
-
<div class="col-lg-
|
|
67
|
+
<div class="col-lg-4 col-md-6">
|
|
81
68
|
<div class="card h-100">
|
|
82
69
|
<div class="card-body text-center">
|
|
83
70
|
<div class="rounded-circle d-inline-flex align-items-center justify-content-center p-3 bg-warning bg-opacity-10 mx-auto mb-2">
|
|
@@ -85,8 +72,9 @@ prerender_icons:
|
|
|
85
72
|
</div>
|
|
86
73
|
<h6 class="text-muted mb-1">Push subscribers</h6>
|
|
87
74
|
<h3 class="mb-0" id="stat-notifications">
|
|
88
|
-
<span class="spinner-border spinner-border-sm"></span>
|
|
75
|
+
<span class="spinner-border spinner-border-sm fs-6"></span>
|
|
89
76
|
</h3>
|
|
77
|
+
<small class="text-muted" id="stat-new-notifications"></small>
|
|
90
78
|
</div>
|
|
91
79
|
</div>
|
|
92
80
|
</div>
|
|
@@ -58,8 +58,9 @@ prerender_icons:
|
|
|
58
58
|
</div>
|
|
59
59
|
<h6 class="text-muted mb-1">Total users</h6>
|
|
60
60
|
<h3 class="mb-0" id="stat-total-users">
|
|
61
|
-
<span class="spinner-border spinner-border-sm"></span>
|
|
61
|
+
<span class="spinner-border spinner-border-sm fs-6"></span>
|
|
62
62
|
</h3>
|
|
63
|
+
<small class="text-muted" id="stat-new-users"></small>
|
|
63
64
|
</div>
|
|
64
65
|
</div>
|
|
65
66
|
</div>
|
|
@@ -73,22 +74,22 @@ prerender_icons:
|
|
|
73
74
|
</div>
|
|
74
75
|
<h6 class="text-muted mb-1">Active subscriptions</h6>
|
|
75
76
|
<h3 class="mb-0" id="stat-active-subs">
|
|
76
|
-
<span class="spinner-border spinner-border-sm"></span>
|
|
77
|
+
<span class="spinner-border spinner-border-sm fs-6"></span>
|
|
77
78
|
</h3>
|
|
78
79
|
</div>
|
|
79
80
|
</div>
|
|
80
81
|
</div>
|
|
81
82
|
|
|
82
|
-
<!--
|
|
83
|
+
<!-- Active Users (30d) -->
|
|
83
84
|
<div class="col-lg-4 col-md-6">
|
|
84
85
|
<div class="card h-100">
|
|
85
86
|
<div class="card-body text-center">
|
|
86
87
|
<div class="rounded-circle d-inline-flex align-items-center justify-content-center p-3 bg-info bg-opacity-10 mx-auto mb-2">
|
|
87
88
|
{% uj_icon "user-plus", "fa-xl text-info" %}
|
|
88
89
|
</div>
|
|
89
|
-
<h6 class="text-muted mb-1">
|
|
90
|
-
<h3 class="mb-0" id="stat-
|
|
91
|
-
<span class="spinner-border spinner-border-sm"></span>
|
|
90
|
+
<h6 class="text-muted mb-1">Active users (30d)</h6>
|
|
91
|
+
<h3 class="mb-0" id="stat-active-users">
|
|
92
|
+
<span class="spinner-border spinner-border-sm fs-6"></span>
|
|
92
93
|
</h3>
|
|
93
94
|
</div>
|
|
94
95
|
</div>
|
|
@@ -46,156 +46,158 @@ sitemap:
|
|
|
46
46
|
import { initializeApp } from 'https://www.gstatic.com/firebasejs/12.11.0/firebase-app.js';
|
|
47
47
|
import { getAuth, onAuthStateChanged } from 'https://www.gstatic.com/firebasejs/12.11.0/firebase-auth.js';
|
|
48
48
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
49
|
+
(async function() {
|
|
50
|
+
const config = {{ page.resolved.web_manager.firebase.app.config | jsonify }};
|
|
51
|
+
|
|
52
|
+
document.getElementById('ua').textContent = navigator.userAgent;
|
|
53
|
+
|
|
54
|
+
// Unregister all service workers first
|
|
55
|
+
if ('serviceWorker' in navigator) {
|
|
56
|
+
const regs = await navigator.serviceWorker.getRegistrations();
|
|
57
|
+
console.log(`[Setup] Found ${regs.length} service worker(s)`);
|
|
58
|
+
for (const reg of regs) {
|
|
59
|
+
const result = await reg.unregister();
|
|
60
|
+
console.log(`[Setup] Unregistered service worker: ${reg.scope} (success: ${result})`);
|
|
61
|
+
}
|
|
62
|
+
const swMsg = regs.length > 0
|
|
63
|
+
? `Unregistered ${regs.length} service worker(s)`
|
|
64
|
+
: 'No service workers found';
|
|
65
|
+
document.getElementById('sw-status').textContent = swMsg;
|
|
66
|
+
document.getElementById('status').textContent = swMsg + '. Running tests...';
|
|
67
|
+
} else {
|
|
68
|
+
console.log('[Setup] Service workers not supported');
|
|
69
|
+
document.getElementById('sw-status').textContent = 'Service workers not supported';
|
|
70
|
+
}
|
|
52
71
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
72
|
+
function makeLogger(id) {
|
|
73
|
+
const $log = document.getElementById(id + '-log');
|
|
74
|
+
const $box = document.getElementById(id);
|
|
75
|
+
return {
|
|
76
|
+
log: (msg) => {
|
|
77
|
+
console.log(`[${id}]`, msg);
|
|
78
|
+
$log.textContent += msg + '\n';
|
|
79
|
+
$log.scrollTop = $log.scrollHeight;
|
|
80
|
+
},
|
|
81
|
+
pass: () => { $box.classList.add('success'); return 'PASS'; },
|
|
82
|
+
fail: () => { $box.classList.add('fail'); return 'FAIL'; },
|
|
83
|
+
};
|
|
60
84
|
}
|
|
61
|
-
const swMsg = regs.length > 0
|
|
62
|
-
? `Unregistered ${regs.length} service worker(s)`
|
|
63
|
-
: 'No service workers found';
|
|
64
|
-
document.getElementById('sw-status').textContent = swMsg;
|
|
65
|
-
document.getElementById('status').textContent = swMsg + '. Running tests...';
|
|
66
|
-
} else {
|
|
67
|
-
console.log('[Setup] Service workers not supported');
|
|
68
|
-
document.getElementById('sw-status').textContent = 'Service workers not supported';
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
function makeLogger(id) {
|
|
72
|
-
const $log = document.getElementById(id + '-log');
|
|
73
|
-
const $box = document.getElementById(id);
|
|
74
|
-
return {
|
|
75
|
-
log: (msg) => {
|
|
76
|
-
console.log(`[${id}]`, msg);
|
|
77
|
-
$log.textContent += msg + '\n';
|
|
78
|
-
$log.scrollTop = $log.scrollHeight;
|
|
79
|
-
},
|
|
80
|
-
pass: () => { $box.classList.add('success'); return 'PASS'; },
|
|
81
|
-
fail: () => { $box.classList.add('fail'); return 'FAIL'; },
|
|
82
|
-
};
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
const results = {};
|
|
86
|
-
|
|
87
|
-
// REST baseline
|
|
88
|
-
async function testRest(uid, token) {
|
|
89
|
-
const t = makeLogger('rest');
|
|
90
|
-
try {
|
|
91
|
-
const url = `https://firestore.googleapis.com/v1/projects/${config.projectId}/databases/(default)/documents/users/${uid}`;
|
|
92
|
-
t.log('Fetching via REST...');
|
|
93
|
-
const res = await fetch(url, { headers: { 'Authorization': `Bearer ${token}` } });
|
|
94
|
-
t.log(`Status: ${res.status}`);
|
|
95
|
-
if (res.ok) {
|
|
96
|
-
const data = await res.json();
|
|
97
|
-
t.log('SUCCESS');
|
|
98
|
-
results['REST'] = t.pass();
|
|
99
|
-
} else {
|
|
100
|
-
const text = await res.text();
|
|
101
|
-
t.log('ERROR: ' + text.substring(0, 150));
|
|
102
|
-
results['REST'] = t.fail();
|
|
103
|
-
}
|
|
104
|
-
} catch (e) { t.log('ERROR: ' + e.message); results['REST'] = t.fail(); }
|
|
105
|
-
}
|
|
106
85
|
|
|
107
|
-
|
|
108
|
-
async function testVersion(id, version, uid, useLP) {
|
|
109
|
-
const t = makeLogger(id);
|
|
110
|
-
try {
|
|
111
|
-
const label = useLP ? 'initializeFirestore+forceLongPolling' : 'getFirestore';
|
|
112
|
-
t.log(`Loading Firebase ${version}...`);
|
|
86
|
+
const results = {};
|
|
113
87
|
|
|
114
|
-
|
|
115
|
-
|
|
88
|
+
// REST baseline
|
|
89
|
+
async function testRest(uid, token) {
|
|
90
|
+
const t = makeLogger('rest');
|
|
91
|
+
try {
|
|
92
|
+
const url = `https://firestore.googleapis.com/v1/projects/${config.projectId}/databases/(default)/documents/users/${uid}`;
|
|
93
|
+
t.log('Fetching via REST...');
|
|
94
|
+
const res = await fetch(url, { headers: { 'Authorization': `Bearer ${token}` } });
|
|
95
|
+
t.log(`Status: ${res.status}`);
|
|
96
|
+
if (res.ok) {
|
|
97
|
+
const data = await res.json();
|
|
98
|
+
t.log('SUCCESS');
|
|
99
|
+
results['REST'] = t.pass();
|
|
100
|
+
} else {
|
|
101
|
+
const text = await res.text();
|
|
102
|
+
t.log('ERROR: ' + text.substring(0, 150));
|
|
103
|
+
results['REST'] = t.fail();
|
|
104
|
+
}
|
|
105
|
+
} catch (e) { t.log('ERROR: ' + e.message); results['REST'] = t.fail(); }
|
|
106
|
+
}
|
|
116
107
|
|
|
117
|
-
|
|
118
|
-
|
|
108
|
+
// Generic version test using dynamic import
|
|
109
|
+
async function testVersion(id, version, uid, useLP) {
|
|
110
|
+
const t = makeLogger(id);
|
|
119
111
|
try {
|
|
120
|
-
|
|
112
|
+
const label = useLP ? 'initializeFirestore+forceLongPolling' : 'getFirestore';
|
|
113
|
+
t.log(`Loading Firebase ${version}...`);
|
|
114
|
+
|
|
115
|
+
const appMod = await import(`https://www.gstatic.com/firebasejs/${version}/firebase-app.js`);
|
|
116
|
+
const fsMod = await import(`https://www.gstatic.com/firebasejs/${version}/firebase-firestore.js`);
|
|
117
|
+
|
|
118
|
+
const appName = id;
|
|
119
|
+
let app;
|
|
120
|
+
try {
|
|
121
|
+
app = appMod.initializeApp(config, appName);
|
|
122
|
+
} catch (e) {
|
|
123
|
+
app = appMod.getApp(appName);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
let db;
|
|
127
|
+
if (useLP) {
|
|
128
|
+
t.log(`initializeFirestore + experimentalForceLongPolling...`);
|
|
129
|
+
db = fsMod.initializeFirestore(app, { experimentalForceLongPolling: true });
|
|
130
|
+
} else {
|
|
131
|
+
t.log(`getFirestore...`);
|
|
132
|
+
db = fsMod.getFirestore(app);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
t.log(`Fetching users/${uid} (10s timeout)...`);
|
|
136
|
+
|
|
137
|
+
// Race against a timeout
|
|
138
|
+
const fetchPromise = fsMod.getDoc(fsMod.doc(db, 'users', uid));
|
|
139
|
+
const timeoutPromise = new Promise((_, reject) =>
|
|
140
|
+
setTimeout(() => reject(new Error('TIMEOUT after 10s')), 10000)
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
const snap = await Promise.race([fetchPromise, timeoutPromise]);
|
|
144
|
+
|
|
145
|
+
if (snap.exists()) {
|
|
146
|
+
t.log('SUCCESS: ' + JSON.stringify(snap.data()).substring(0, 100));
|
|
147
|
+
results[id] = t.pass();
|
|
148
|
+
} else {
|
|
149
|
+
t.log('Doc not found');
|
|
150
|
+
results[id] = t.fail();
|
|
151
|
+
}
|
|
121
152
|
} catch (e) {
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
t.log(`getFirestore...`);
|
|
131
|
-
db = fsMod.getFirestore(app);
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
t.log(`Fetching users/${uid} (10s timeout)...`);
|
|
135
|
-
|
|
136
|
-
// Race against a timeout
|
|
137
|
-
const fetchPromise = fsMod.getDoc(fsMod.doc(db, 'users', uid));
|
|
138
|
-
const timeoutPromise = new Promise((_, reject) =>
|
|
139
|
-
setTimeout(() => reject(new Error('TIMEOUT after 10s')), 10000)
|
|
140
|
-
);
|
|
141
|
-
|
|
142
|
-
const snap = await Promise.race([fetchPromise, timeoutPromise]);
|
|
143
|
-
|
|
144
|
-
if (snap.exists()) {
|
|
145
|
-
t.log('SUCCESS: ' + JSON.stringify(snap.data()).substring(0, 100));
|
|
146
|
-
results[id] = t.pass();
|
|
147
|
-
} else {
|
|
148
|
-
t.log('Doc not found');
|
|
149
|
-
results[id] = t.fail();
|
|
150
|
-
}
|
|
151
|
-
} catch (e) {
|
|
152
|
-
const code = e.code || '';
|
|
153
|
-
if (code === 'permission-denied') {
|
|
154
|
-
t.log('TRANSPORT OK (permission-denied = network works, just no auth)');
|
|
155
|
-
results[id] = t.pass();
|
|
156
|
-
} else {
|
|
157
|
-
t.log(`ERROR: ${code} ${e.message}`);
|
|
158
|
-
results[id] = t.fail();
|
|
153
|
+
const code = e.code || '';
|
|
154
|
+
if (code === 'permission-denied') {
|
|
155
|
+
t.log('TRANSPORT OK (permission-denied = network works, just no auth)');
|
|
156
|
+
results[id] = t.pass();
|
|
157
|
+
} else {
|
|
158
|
+
t.log(`ERROR: ${code} ${e.message}`);
|
|
159
|
+
results[id] = t.fail();
|
|
160
|
+
}
|
|
159
161
|
}
|
|
160
162
|
}
|
|
161
|
-
}
|
|
162
163
|
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
164
|
+
// Main
|
|
165
|
+
const defaultApp = initializeApp(config);
|
|
166
|
+
const auth = getAuth(defaultApp);
|
|
166
167
|
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
168
|
+
onAuthStateChanged(auth, async (user) => {
|
|
169
|
+
if (!user) {
|
|
170
|
+
document.getElementById('status').textContent = 'Not signed in. Sign in at /dashboard first.';
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
172
173
|
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
174
|
+
const uid = user.uid;
|
|
175
|
+
const token = await user.getIdToken();
|
|
176
|
+
document.getElementById('status').textContent = `Signed in: ${uid}. Running tests...`;
|
|
177
|
+
|
|
178
|
+
// Run REST first
|
|
179
|
+
await testRest(uid, token);
|
|
180
|
+
|
|
181
|
+
// Run version tests (sequential to avoid interference)
|
|
182
|
+
await testVersion('v10', '10.14.0', uid, false);
|
|
183
|
+
await testVersion('v10lp', '10.14.0', uid, true);
|
|
184
|
+
await testVersion('v11', '11.0.0', uid, false);
|
|
185
|
+
await testVersion('v11lp', '11.0.0', uid, true);
|
|
186
|
+
await testVersion('v12_0', '12.0.0', uid, false);
|
|
187
|
+
await testVersion('v12_0lp', '12.0.0', uid, true);
|
|
188
|
+
await testVersion('v12_11', '12.11.0', uid, false);
|
|
189
|
+
await testVersion('v12_11lp', '12.11.0', uid, true);
|
|
190
|
+
|
|
191
|
+
// Show summary
|
|
192
|
+
document.getElementById('status').textContent = 'All tests complete.';
|
|
193
|
+
const $summary = document.getElementById('summary');
|
|
194
|
+
$summary.style.display = 'block';
|
|
195
|
+
$summary.innerHTML = '<h2 style="margin-bottom:8px">Results Summary</h2>' +
|
|
196
|
+
Object.entries(results).map(([k, v]) =>
|
|
197
|
+
`<div style="color:${v === 'PASS' ? '#0f0' : '#f00'}">${v}: ${k}</div>`
|
|
198
|
+
).join('');
|
|
199
|
+
});
|
|
200
|
+
})();
|
|
199
201
|
</script>
|
|
200
202
|
</body>
|
|
201
203
|
</html>
|