@intranefr/superbackend 1.4.3
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/.commiat +4 -0
- package/.env.example +47 -0
- package/README.md +110 -0
- package/index.js +94 -0
- package/package.json +67 -0
- package/public/css/styles.css +139 -0
- package/public/js/animations.js +41 -0
- package/sdk/error-tracking/browser/package.json +16 -0
- package/sdk/error-tracking/browser/src/core.js +270 -0
- package/sdk/error-tracking/browser/src/embed.js +18 -0
- package/sdk/error-tracking/browser/src/index.js +1 -0
- package/server.js +5 -0
- package/src/admin/endpointRegistry.js +300 -0
- package/src/controllers/admin.controller.js +321 -0
- package/src/controllers/adminAssets.controller.js +530 -0
- package/src/controllers/adminAssetsStorage.controller.js +260 -0
- package/src/controllers/adminEjsVirtual.controller.js +354 -0
- package/src/controllers/adminFeatureFlags.controller.js +155 -0
- package/src/controllers/adminHeadless.controller.js +1071 -0
- package/src/controllers/adminI18n.controller.js +604 -0
- package/src/controllers/adminJsonConfigs.controller.js +97 -0
- package/src/controllers/adminLlm.controller.js +273 -0
- package/src/controllers/adminMigration.controller.js +257 -0
- package/src/controllers/adminSeoConfig.controller.js +515 -0
- package/src/controllers/adminStats.controller.js +121 -0
- package/src/controllers/adminUploadNamespaces.controller.js +208 -0
- package/src/controllers/assets.controller.js +248 -0
- package/src/controllers/auth.controller.js +93 -0
- package/src/controllers/billing.controller.js +223 -0
- package/src/controllers/featureFlags.controller.js +35 -0
- package/src/controllers/forms.controller.js +217 -0
- package/src/controllers/globalSettings.controller.js +252 -0
- package/src/controllers/headlessCrud.controller.js +126 -0
- package/src/controllers/i18n.controller.js +12 -0
- package/src/controllers/invite.controller.js +249 -0
- package/src/controllers/jsonConfigs.controller.js +19 -0
- package/src/controllers/metrics.controller.js +149 -0
- package/src/controllers/notificationAdmin.controller.js +264 -0
- package/src/controllers/notifications.controller.js +131 -0
- package/src/controllers/org.controller.js +357 -0
- package/src/controllers/orgAdmin.controller.js +491 -0
- package/src/controllers/stripeAdmin.controller.js +410 -0
- package/src/controllers/user.controller.js +361 -0
- package/src/controllers/userAdmin.controller.js +277 -0
- package/src/controllers/waitingList.controller.js +167 -0
- package/src/controllers/webhook.controller.js +200 -0
- package/src/middleware/auth.js +66 -0
- package/src/middleware/errorCapture.js +170 -0
- package/src/middleware/headlessApiTokenAuth.js +57 -0
- package/src/middleware/org.js +108 -0
- package/src/middleware.js +901 -0
- package/src/models/ActionEvent.js +31 -0
- package/src/models/ActivityLog.js +41 -0
- package/src/models/Asset.js +84 -0
- package/src/models/AuditEvent.js +93 -0
- package/src/models/EmailLog.js +28 -0
- package/src/models/ErrorAggregate.js +72 -0
- package/src/models/FormSubmission.js +41 -0
- package/src/models/GlobalSetting.js +38 -0
- package/src/models/HeadlessApiToken.js +24 -0
- package/src/models/HeadlessModelDefinition.js +41 -0
- package/src/models/I18nEntry.js +77 -0
- package/src/models/I18nLocale.js +33 -0
- package/src/models/Invite.js +70 -0
- package/src/models/JsonConfig.js +46 -0
- package/src/models/Notification.js +60 -0
- package/src/models/Organization.js +57 -0
- package/src/models/OrganizationMember.js +43 -0
- package/src/models/StripeCatalogItem.js +77 -0
- package/src/models/StripeWebhookEvent.js +57 -0
- package/src/models/User.js +89 -0
- package/src/models/VirtualEjsFile.js +60 -0
- package/src/models/VirtualEjsFileVersion.js +43 -0
- package/src/models/VirtualEjsGroupChange.js +32 -0
- package/src/models/WaitingList.js +41 -0
- package/src/models/Webhook.js +63 -0
- package/src/models/Workflow.js +29 -0
- package/src/models/WorkflowExecution.js +12 -0
- package/src/routes/admin.routes.js +26 -0
- package/src/routes/adminAssets.routes.js +28 -0
- package/src/routes/adminAssetsStorage.routes.js +13 -0
- package/src/routes/adminAudit.routes.js +196 -0
- package/src/routes/adminEjsVirtual.routes.js +17 -0
- package/src/routes/adminErrors.routes.js +164 -0
- package/src/routes/adminFeatureFlags.routes.js +12 -0
- package/src/routes/adminHeadless.routes.js +38 -0
- package/src/routes/adminI18n.routes.js +22 -0
- package/src/routes/adminJsonConfigs.routes.js +15 -0
- package/src/routes/adminLlm.routes.js +12 -0
- package/src/routes/adminMigration.routes.js +81 -0
- package/src/routes/adminSeoConfig.routes.js +20 -0
- package/src/routes/adminUploadNamespaces.routes.js +13 -0
- package/src/routes/assets.routes.js +21 -0
- package/src/routes/auth.routes.js +12 -0
- package/src/routes/billing.routes.js +11 -0
- package/src/routes/errorTracking.routes.js +31 -0
- package/src/routes/featureFlags.routes.js +9 -0
- package/src/routes/forms.routes.js +9 -0
- package/src/routes/formsAdmin.routes.js +13 -0
- package/src/routes/globalSettings.routes.js +18 -0
- package/src/routes/headless.routes.js +15 -0
- package/src/routes/i18n.routes.js +8 -0
- package/src/routes/invite.routes.js +9 -0
- package/src/routes/jsonConfigs.routes.js +8 -0
- package/src/routes/log.routes.js +111 -0
- package/src/routes/metrics.routes.js +9 -0
- package/src/routes/notificationAdmin.routes.js +15 -0
- package/src/routes/notifications.routes.js +12 -0
- package/src/routes/org.routes.js +31 -0
- package/src/routes/orgAdmin.routes.js +20 -0
- package/src/routes/publicAssets.routes.js +7 -0
- package/src/routes/stripeAdmin.routes.js +20 -0
- package/src/routes/user.routes.js +22 -0
- package/src/routes/userAdmin.routes.js +15 -0
- package/src/routes/waitingList.routes.js +13 -0
- package/src/routes/waitingListAdmin.routes.js +9 -0
- package/src/routes/webhook.routes.js +32 -0
- package/src/routes/workflowWebhook.routes.js +54 -0
- package/src/routes/workflows.routes.js +110 -0
- package/src/services/assets.service.js +110 -0
- package/src/services/audit.service.js +62 -0
- package/src/services/auditLogger.js +165 -0
- package/src/services/ejsVirtual.service.js +614 -0
- package/src/services/email.service.js +351 -0
- package/src/services/errorLogger.js +221 -0
- package/src/services/featureFlags.service.js +202 -0
- package/src/services/forms.service.js +214 -0
- package/src/services/globalSettings.service.js +49 -0
- package/src/services/headlessApiTokens.service.js +158 -0
- package/src/services/headlessCrypto.service.js +31 -0
- package/src/services/headlessModels.service.js +356 -0
- package/src/services/i18n.service.js +314 -0
- package/src/services/i18nInferredKeys.service.js +337 -0
- package/src/services/jsonConfigs.service.js +392 -0
- package/src/services/llm.service.js +749 -0
- package/src/services/migration.service.js +581 -0
- package/src/services/migrationAssets/fsLocal.js +58 -0
- package/src/services/migrationAssets/index.js +134 -0
- package/src/services/migrationAssets/s3.js +75 -0
- package/src/services/migrationAssets/sftp.js +92 -0
- package/src/services/notification.service.js +212 -0
- package/src/services/objectStorage.service.js +514 -0
- package/src/services/seoConfig.service.js +402 -0
- package/src/services/storage.js +150 -0
- package/src/services/stripe.service.js +185 -0
- package/src/services/stripeHelper.service.js +264 -0
- package/src/services/uploadNamespaces.service.js +326 -0
- package/src/services/webhook.service.js +157 -0
- package/src/services/workflow.service.js +271 -0
- package/src/utils/asyncHandler.js +5 -0
- package/src/utils/encryption.js +80 -0
- package/src/utils/jwt.js +40 -0
- package/src/utils/orgRoles.js +156 -0
- package/src/utils/validation.js +26 -0
- package/src/utils/webhookRetry.js +93 -0
- package/views/admin-assets.ejs +444 -0
- package/views/admin-audit.ejs +283 -0
- package/views/admin-coolify-deploy.ejs +207 -0
- package/views/admin-dashboard-home.ejs +291 -0
- package/views/admin-dashboard.ejs +397 -0
- package/views/admin-ejs-virtual.ejs +280 -0
- package/views/admin-errors.ejs +368 -0
- package/views/admin-feature-flags.ejs +390 -0
- package/views/admin-forms.ejs +526 -0
- package/views/admin-global-settings.ejs +436 -0
- package/views/admin-headless.ejs +2020 -0
- package/views/admin-i18n-locales.ejs +221 -0
- package/views/admin-i18n.ejs +728 -0
- package/views/admin-json-configs.ejs +410 -0
- package/views/admin-llm.ejs +884 -0
- package/views/admin-metrics.ejs +274 -0
- package/views/admin-migration.ejs +814 -0
- package/views/admin-notifications.ejs +430 -0
- package/views/admin-organizations.ejs +984 -0
- package/views/admin-seo-config.ejs +673 -0
- package/views/admin-stripe-pricing.ejs +558 -0
- package/views/admin-test.ejs +342 -0
- package/views/admin-users.ejs +452 -0
- package/views/admin-waiting-list.ejs +547 -0
- package/views/admin-webhooks.ejs +329 -0
- package/views/admin-workflows.ejs +310 -0
- package/views/partials/admin-assets-script.ejs +2022 -0
- package/views/partials/admin-test-sidebar.ejs +14 -0
- package/views/partials/dashboard/nav-items.ejs +66 -0
- package/views/partials/dashboard/palette.ejs +63 -0
- package/views/partials/dashboard/sidebar.ejs +21 -0
- package/views/partials/dashboard/tab-bar.ejs +26 -0
- package/views/partials/footer.ejs +3 -0
|
@@ -0,0 +1,547 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
+
<title>Waiting List Admin</title>
|
|
7
|
+
<script src="https://cdn.tailwindcss.com"></script>
|
|
8
|
+
<style>
|
|
9
|
+
.toast {
|
|
10
|
+
animation: slideIn 0.3s ease-out;
|
|
11
|
+
}
|
|
12
|
+
@keyframes slideIn {
|
|
13
|
+
from { transform: translateX(100%); opacity: 0; }
|
|
14
|
+
to { transform: translateX(0); opacity: 1; }
|
|
15
|
+
}
|
|
16
|
+
.fade-out {
|
|
17
|
+
animation: fadeOut 0.3s ease-out forwards;
|
|
18
|
+
}
|
|
19
|
+
@keyframes fadeOut {
|
|
20
|
+
from { opacity: 1; }
|
|
21
|
+
to { opacity: 0; }
|
|
22
|
+
}
|
|
23
|
+
</style>
|
|
24
|
+
</head>
|
|
25
|
+
<body class="bg-gray-100">
|
|
26
|
+
<div class="min-h-screen">
|
|
27
|
+
<!-- Header -->
|
|
28
|
+
<div class="bg-white shadow">
|
|
29
|
+
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
|
|
30
|
+
<div class="flex justify-between items-center">
|
|
31
|
+
<div>
|
|
32
|
+
<h1 class="text-2xl font-bold text-gray-900">Waiting List</h1>
|
|
33
|
+
<p class="text-sm text-gray-600 mt-1">Stats + subscription tester</p>
|
|
34
|
+
</div>
|
|
35
|
+
<div class="flex items-center gap-4">
|
|
36
|
+
</div>
|
|
37
|
+
</div>
|
|
38
|
+
</div>
|
|
39
|
+
</div>
|
|
40
|
+
|
|
41
|
+
<!-- Main Content -->
|
|
42
|
+
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
|
43
|
+
<!-- KPI Cards -->
|
|
44
|
+
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 mb-6">
|
|
45
|
+
<div class="bg-white rounded-lg shadow p-6">
|
|
46
|
+
<p class="text-sm text-gray-500">Total Subscribers</p>
|
|
47
|
+
<p id="kpi-total" class="text-3xl font-bold text-gray-900 mt-2">-</p>
|
|
48
|
+
</div>
|
|
49
|
+
<div class="bg-white rounded-lg shadow p-6">
|
|
50
|
+
<p class="text-sm text-gray-500">Growth This Week</p>
|
|
51
|
+
<p id="kpi-growth" class="text-3xl font-bold text-gray-900 mt-2">-</p>
|
|
52
|
+
</div>
|
|
53
|
+
<div class="bg-white rounded-lg shadow p-6">
|
|
54
|
+
<p class="text-sm text-gray-500">Last Updated</p>
|
|
55
|
+
<p id="kpi-updated" class="text-sm font-medium text-gray-900 mt-2">-</p>
|
|
56
|
+
</div>
|
|
57
|
+
</div>
|
|
58
|
+
|
|
59
|
+
<div class="bg-white rounded-lg shadow p-6 mb-6">
|
|
60
|
+
<div class="flex items-center justify-between gap-4">
|
|
61
|
+
<div>
|
|
62
|
+
<h2 class="text-lg font-semibold text-gray-900">Breakdown by Type</h2>
|
|
63
|
+
<p class="text-sm text-gray-600 mt-1">Auto-generated from current data</p>
|
|
64
|
+
</div>
|
|
65
|
+
</div>
|
|
66
|
+
<div id="type-cards" class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mt-4"></div>
|
|
67
|
+
</div>
|
|
68
|
+
|
|
69
|
+
<div class="flex gap-2 mb-8">
|
|
70
|
+
<button id="btn-refresh-stats" class="bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600">Refresh Stats</button>
|
|
71
|
+
</div>
|
|
72
|
+
|
|
73
|
+
<!-- Entries table (admin) -->
|
|
74
|
+
<div class="bg-white rounded-lg shadow p-6 mb-8">
|
|
75
|
+
<div class="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
|
|
76
|
+
<div>
|
|
77
|
+
<h2 class="text-xl font-bold text-gray-900">Entries</h2>
|
|
78
|
+
<p id="entries-subtitle" class="text-sm text-gray-600 mt-1">Loading...</p>
|
|
79
|
+
</div>
|
|
80
|
+
<div class="flex gap-2">
|
|
81
|
+
<button id="btn-copy-visible" class="bg-gray-100 text-gray-800 px-4 py-2 rounded hover:bg-gray-200">Copy visible</button>
|
|
82
|
+
<button id="btn-refresh-entries" class="bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600">Refresh</button>
|
|
83
|
+
</div>
|
|
84
|
+
</div>
|
|
85
|
+
|
|
86
|
+
<div class="grid grid-cols-1 sm:grid-cols-4 gap-4 mt-4">
|
|
87
|
+
<div>
|
|
88
|
+
<label class="block text-sm font-medium text-gray-700 mb-1">Status</label>
|
|
89
|
+
<input id="entries-status" type="text" class="w-full border rounded px-3 py-2" placeholder="active">
|
|
90
|
+
</div>
|
|
91
|
+
<div>
|
|
92
|
+
<label class="block text-sm font-medium text-gray-700 mb-1">Type</label>
|
|
93
|
+
<input id="entries-type" type="text" class="w-full border rounded px-3 py-2" placeholder="e.g., buyer">
|
|
94
|
+
</div>
|
|
95
|
+
<div>
|
|
96
|
+
<label class="block text-sm font-medium text-gray-700 mb-1">Email (exact)</label>
|
|
97
|
+
<input id="entries-email" type="text" class="w-full border rounded px-3 py-2" placeholder="user@example.com">
|
|
98
|
+
</div>
|
|
99
|
+
<div>
|
|
100
|
+
<label class="block text-sm font-medium text-gray-700 mb-1">Page size</label>
|
|
101
|
+
<select id="entries-limit" class="w-full border rounded px-3 py-2">
|
|
102
|
+
<option value="20">20</option>
|
|
103
|
+
<option value="50" selected>50</option>
|
|
104
|
+
<option value="100">100</option>
|
|
105
|
+
<option value="200">200</option>
|
|
106
|
+
</select>
|
|
107
|
+
</div>
|
|
108
|
+
</div>
|
|
109
|
+
|
|
110
|
+
<div class="flex gap-2 mt-4">
|
|
111
|
+
<button id="btn-entries-apply" class="bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600">Apply</button>
|
|
112
|
+
<button id="btn-entries-reset" class="bg-gray-100 text-gray-800 px-4 py-2 rounded hover:bg-gray-200">Reset</button>
|
|
113
|
+
<div class="flex-1"></div>
|
|
114
|
+
<button id="btn-entries-prev" class="bg-gray-100 text-gray-800 px-4 py-2 rounded hover:bg-gray-200">Prev</button>
|
|
115
|
+
<button id="btn-entries-next" class="bg-gray-100 text-gray-800 px-4 py-2 rounded hover:bg-gray-200">Next</button>
|
|
116
|
+
</div>
|
|
117
|
+
|
|
118
|
+
<div class="overflow-x-auto mt-4">
|
|
119
|
+
<table class="min-w-full divide-y divide-gray-200">
|
|
120
|
+
<thead class="bg-gray-50">
|
|
121
|
+
<tr>
|
|
122
|
+
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Created</th>
|
|
123
|
+
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Email</th>
|
|
124
|
+
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Type</th>
|
|
125
|
+
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
|
|
126
|
+
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Referral</th>
|
|
127
|
+
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
|
|
128
|
+
</tr>
|
|
129
|
+
</thead>
|
|
130
|
+
<tbody id="entries-tbody" class="bg-white divide-y divide-gray-200"></tbody>
|
|
131
|
+
</table>
|
|
132
|
+
</div>
|
|
133
|
+
</div>
|
|
134
|
+
|
|
135
|
+
<!-- Subscribe tester -->
|
|
136
|
+
<div class="bg-white rounded-lg shadow p-6">
|
|
137
|
+
<div>
|
|
138
|
+
<h2 class="text-xl font-bold text-gray-900">Subscribe Test</h2>
|
|
139
|
+
<p class="text-sm text-gray-600 mt-1">POST /api/waiting-list/subscribe</p>
|
|
140
|
+
</div>
|
|
141
|
+
|
|
142
|
+
<form id="subscribe-form" class="mt-6">
|
|
143
|
+
<div class="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
|
144
|
+
<div>
|
|
145
|
+
<label class="block text-sm font-medium text-gray-700 mb-1">Email *</label>
|
|
146
|
+
<input id="sub-email" type="email" class="w-full border rounded px-3 py-2" placeholder="user@example.com" required>
|
|
147
|
+
</div>
|
|
148
|
+
<div>
|
|
149
|
+
<label class="block text-sm font-medium text-gray-700 mb-1">Type *</label>
|
|
150
|
+
<input id="sub-type" type="text" class="w-full border rounded px-3 py-2" placeholder="e.g., buyer" required>
|
|
151
|
+
</div>
|
|
152
|
+
<div>
|
|
153
|
+
<label class="block text-sm font-medium text-gray-700 mb-1">Referral Source</label>
|
|
154
|
+
<input id="sub-ref" type="text" class="w-full border rounded px-3 py-2" placeholder="website">
|
|
155
|
+
</div>
|
|
156
|
+
</div>
|
|
157
|
+
|
|
158
|
+
<div class="mt-4 flex gap-2">
|
|
159
|
+
<button type="submit" class="bg-green-500 text-white px-4 py-2 rounded hover:bg-green-600">Subscribe</button>
|
|
160
|
+
<button type="button" id="btn-sub-clear" class="bg-gray-100 text-gray-800 px-4 py-2 rounded hover:bg-gray-200">Clear</button>
|
|
161
|
+
</div>
|
|
162
|
+
</form>
|
|
163
|
+
|
|
164
|
+
<div class="mt-6">
|
|
165
|
+
<h3 class="font-semibold mb-2">Response</h3>
|
|
166
|
+
<pre id="subscribe-response" class="bg-gray-100 p-4 rounded overflow-auto max-h-96"></pre>
|
|
167
|
+
</div>
|
|
168
|
+
</div>
|
|
169
|
+
</div>
|
|
170
|
+
</div>
|
|
171
|
+
|
|
172
|
+
<!-- Toast Container -->
|
|
173
|
+
<div id="toast-container" class="fixed top-4 right-4 space-y-2 z-50"></div>
|
|
174
|
+
|
|
175
|
+
<script>
|
|
176
|
+
const API_BASE = window.location.origin + "<%= baseUrl %>" || window.location.origin;
|
|
177
|
+
const WAITING_LIST_STATS_PATH = '/api/waiting-list/stats';
|
|
178
|
+
const WAITING_LIST_SUBSCRIBE_PATH = '/api/waiting-list/subscribe';
|
|
179
|
+
const WAITING_LIST_ADMIN_LIST_PATH = '/api/admin/waiting-list';
|
|
180
|
+
|
|
181
|
+
function showToast(message, type = 'success') {
|
|
182
|
+
const container = document.getElementById('toast-container');
|
|
183
|
+
const toast = document.createElement('div');
|
|
184
|
+
toast.className = `toast px-6 py-4 rounded-lg shadow-lg text-white ${
|
|
185
|
+
type === 'success' ? 'bg-green-500' : 'bg-red-500'
|
|
186
|
+
}`;
|
|
187
|
+
toast.textContent = message;
|
|
188
|
+
container.appendChild(toast);
|
|
189
|
+
|
|
190
|
+
setTimeout(() => {
|
|
191
|
+
toast.classList.add('fade-out');
|
|
192
|
+
setTimeout(() => toast.remove(), 300);
|
|
193
|
+
}, 3000);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
async function makeRequest(endpoint, method, body = null, requiresJWT = false) {
|
|
197
|
+
const headers = { 'Content-Type': 'application/json' };
|
|
198
|
+
|
|
199
|
+
if (requiresJWT) {
|
|
200
|
+
const token = document.getElementById('jwt-input')?.value;
|
|
201
|
+
if (!token) {
|
|
202
|
+
showToast('JWT token required', 'error');
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
headers['Authorization'] = `Bearer ${token}`;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const options = { method, headers };
|
|
209
|
+
if (body) options.body = JSON.stringify(body);
|
|
210
|
+
|
|
211
|
+
try {
|
|
212
|
+
const response = await fetch(`${API_BASE}${endpoint}`, options);
|
|
213
|
+
const data = await response.json();
|
|
214
|
+
|
|
215
|
+
const responseEl = document.querySelector('#content pre#response');
|
|
216
|
+
if (response.ok) {
|
|
217
|
+
showToast('Request successful!', 'success');
|
|
218
|
+
if (responseEl) responseEl.textContent = JSON.stringify(data, null, 2);
|
|
219
|
+
} else {
|
|
220
|
+
showToast(data.error || 'Request failed', 'error');
|
|
221
|
+
if (responseEl) responseEl.textContent = JSON.stringify(data, null, 2);
|
|
222
|
+
}
|
|
223
|
+
} catch (error) {
|
|
224
|
+
showToast(error.message, 'error');
|
|
225
|
+
const responseEl = document.querySelector('#content pre#response');
|
|
226
|
+
if (responseEl) responseEl.textContent = error.message;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function qs(obj) {
|
|
231
|
+
const params = new URLSearchParams();
|
|
232
|
+
Object.entries(obj).forEach(([k, v]) => {
|
|
233
|
+
if (v === undefined || v === null) return;
|
|
234
|
+
const s = String(v).trim();
|
|
235
|
+
if (!s) return;
|
|
236
|
+
params.set(k, s);
|
|
237
|
+
});
|
|
238
|
+
const out = params.toString();
|
|
239
|
+
return out ? `?${out}` : '';
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function escapeHtml(str) {
|
|
243
|
+
return String(str || '')
|
|
244
|
+
.replaceAll('&', '&')
|
|
245
|
+
.replaceAll('<', '<')
|
|
246
|
+
.replaceAll('>', '>')
|
|
247
|
+
.replaceAll('"', '"')
|
|
248
|
+
.replaceAll("'", ''');
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
async function copyText(text) {
|
|
252
|
+
try {
|
|
253
|
+
await navigator.clipboard.writeText(text);
|
|
254
|
+
showToast('Copied to clipboard', 'success');
|
|
255
|
+
} catch (e) {
|
|
256
|
+
try {
|
|
257
|
+
const ta = document.createElement('textarea');
|
|
258
|
+
ta.value = text;
|
|
259
|
+
ta.style.position = 'fixed';
|
|
260
|
+
ta.style.left = '-9999px';
|
|
261
|
+
document.body.appendChild(ta);
|
|
262
|
+
ta.focus();
|
|
263
|
+
ta.select();
|
|
264
|
+
document.execCommand('copy');
|
|
265
|
+
ta.remove();
|
|
266
|
+
showToast('Copied to clipboard', 'success');
|
|
267
|
+
} catch (err) {
|
|
268
|
+
showToast('Failed to copy', 'error');
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
let entriesState = { offset: 0, total: 0, limit: 50 };
|
|
274
|
+
let lastEntries = [];
|
|
275
|
+
|
|
276
|
+
async function loadEntries() {
|
|
277
|
+
const status = document.getElementById('entries-status')?.value?.trim();
|
|
278
|
+
const type = document.getElementById('entries-type')?.value?.trim();
|
|
279
|
+
const email = document.getElementById('entries-email')?.value?.trim();
|
|
280
|
+
const limit = parseInt(document.getElementById('entries-limit')?.value || '50', 10);
|
|
281
|
+
|
|
282
|
+
const subtitle = document.getElementById('entries-subtitle');
|
|
283
|
+
const tbody = document.getElementById('entries-tbody');
|
|
284
|
+
if (subtitle) subtitle.textContent = 'Loading...';
|
|
285
|
+
if (tbody) tbody.innerHTML = '';
|
|
286
|
+
|
|
287
|
+
try {
|
|
288
|
+
const url = `${API_BASE}${WAITING_LIST_ADMIN_LIST_PATH}${qs({
|
|
289
|
+
status: status || undefined,
|
|
290
|
+
type: type || undefined,
|
|
291
|
+
email: email || undefined,
|
|
292
|
+
limit,
|
|
293
|
+
offset: entriesState.offset,
|
|
294
|
+
})}`;
|
|
295
|
+
|
|
296
|
+
const res = await fetch(url, { headers: { 'Accept': 'application/json' } });
|
|
297
|
+
const data = await res.json();
|
|
298
|
+
|
|
299
|
+
if (!res.ok) {
|
|
300
|
+
showToast(data?.error || 'Failed to load entries', 'error');
|
|
301
|
+
if (subtitle) subtitle.textContent = 'Failed to load.';
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const entries = Array.isArray(data?.entries) ? data.entries : [];
|
|
306
|
+
const pagination = data?.pagination || {};
|
|
307
|
+
const total = Number.isFinite(pagination.total) ? pagination.total : 0;
|
|
308
|
+
const usedLimit = Number.isFinite(pagination.limit) ? pagination.limit : limit;
|
|
309
|
+
const usedOffset = Number.isFinite(pagination.offset) ? pagination.offset : entriesState.offset;
|
|
310
|
+
|
|
311
|
+
entriesState = { offset: usedOffset, total, limit: usedLimit };
|
|
312
|
+
lastEntries = entries;
|
|
313
|
+
|
|
314
|
+
if (subtitle) {
|
|
315
|
+
const from = total === 0 ? 0 : usedOffset + 1;
|
|
316
|
+
const to = Math.min(usedOffset + entries.length, total);
|
|
317
|
+
subtitle.textContent = `${from}-${to} of ${total}`;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
if (tbody) {
|
|
321
|
+
if (entries.length === 0) {
|
|
322
|
+
tbody.innerHTML = `
|
|
323
|
+
<tr>
|
|
324
|
+
<td class="px-4 py-6 text-sm text-gray-600" colspan="6">No entries found.</td>
|
|
325
|
+
</tr>
|
|
326
|
+
`;
|
|
327
|
+
} else {
|
|
328
|
+
tbody.innerHTML = entries.map((e) => {
|
|
329
|
+
const createdAt = escapeHtml(formatDateTime(e?.createdAt));
|
|
330
|
+
const emailCell = escapeHtml(e?.email);
|
|
331
|
+
const typeCell = escapeHtml(e?.type);
|
|
332
|
+
const statusCell = escapeHtml(e?.status);
|
|
333
|
+
const refCell = escapeHtml(e?.referralSource || '');
|
|
334
|
+
const rawEmail = String(e?.email || '');
|
|
335
|
+
|
|
336
|
+
return `
|
|
337
|
+
<tr>
|
|
338
|
+
<td class="px-4 py-3 text-sm text-gray-900 whitespace-nowrap">${createdAt}</td>
|
|
339
|
+
<td class="px-4 py-3 text-sm text-gray-900">${emailCell}</td>
|
|
340
|
+
<td class="px-4 py-3 text-sm text-gray-700">${typeCell}</td>
|
|
341
|
+
<td class="px-4 py-3 text-sm text-gray-700">${statusCell}</td>
|
|
342
|
+
<td class="px-4 py-3 text-sm text-gray-700">${refCell}</td>
|
|
343
|
+
<td class="px-4 py-3 text-sm">
|
|
344
|
+
<button class="text-blue-600 hover:text-blue-800" data-copy-email="${escapeHtml(rawEmail)}">Copy</button>
|
|
345
|
+
</td>
|
|
346
|
+
</tr>
|
|
347
|
+
`;
|
|
348
|
+
}).join('');
|
|
349
|
+
|
|
350
|
+
tbody.querySelectorAll('[data-copy-email]').forEach((btn) => {
|
|
351
|
+
btn.addEventListener('click', () => copyText(btn.getAttribute('data-copy-email') || ''));
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const prevBtn = document.getElementById('btn-entries-prev');
|
|
357
|
+
const nextBtn = document.getElementById('btn-entries-next');
|
|
358
|
+
if (prevBtn) prevBtn.disabled = usedOffset <= 0;
|
|
359
|
+
if (nextBtn) nextBtn.disabled = usedOffset + usedLimit >= total;
|
|
360
|
+
} catch (e) {
|
|
361
|
+
showToast(e.message || 'Failed to load entries', 'error');
|
|
362
|
+
if (subtitle) subtitle.textContent = 'Failed to load.';
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function setText(id, value) {
|
|
367
|
+
const el = document.getElementById(id);
|
|
368
|
+
if (!el) return;
|
|
369
|
+
el.textContent = value;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
function formatDateTime(iso) {
|
|
373
|
+
try {
|
|
374
|
+
return new Date(iso).toLocaleString();
|
|
375
|
+
} catch {
|
|
376
|
+
return String(iso || '');
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
async function loadStats() {
|
|
381
|
+
try {
|
|
382
|
+
const res = await fetch(`${API_BASE}${WAITING_LIST_STATS_PATH}`, { headers: { 'Accept': 'application/json' } });
|
|
383
|
+
const data = await res.json();
|
|
384
|
+
|
|
385
|
+
if (!res.ok) {
|
|
386
|
+
showToast(data?.error || 'Failed to load stats', 'error');
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
setText('kpi-total', String(data?.totalSubscribers ?? '-'));
|
|
391
|
+
setText('kpi-growth', String(data?.growthThisWeek ?? '-'));
|
|
392
|
+
setText('kpi-updated', data?.lastUpdated ? formatDateTime(data.lastUpdated) : '-');
|
|
393
|
+
|
|
394
|
+
const typeCounts = data?.typeCounts && typeof data.typeCounts === 'object' ? data.typeCounts : {};
|
|
395
|
+
const entries = Object.entries(typeCounts)
|
|
396
|
+
.filter(([k]) => k && String(k).trim())
|
|
397
|
+
.sort((a, b) => (b[1] || 0) - (a[1] || 0));
|
|
398
|
+
|
|
399
|
+
const container = document.getElementById('type-cards');
|
|
400
|
+
if (container) {
|
|
401
|
+
if (entries.length === 0) {
|
|
402
|
+
container.innerHTML = '<div class="text-sm text-gray-600">No types found.</div>';
|
|
403
|
+
} else {
|
|
404
|
+
container.innerHTML = entries.map(([type, count]) => {
|
|
405
|
+
const safeType = String(type)
|
|
406
|
+
.replaceAll('&', '&')
|
|
407
|
+
.replaceAll('<', '<')
|
|
408
|
+
.replaceAll('>', '>')
|
|
409
|
+
.replaceAll('"', '"')
|
|
410
|
+
.replaceAll("'", ''');
|
|
411
|
+
|
|
412
|
+
return `
|
|
413
|
+
<div class="bg-gray-50 rounded-lg border p-4">
|
|
414
|
+
<p class="text-sm text-gray-500">${safeType}</p>
|
|
415
|
+
<p class="text-2xl font-bold text-gray-900 mt-2">${count ?? 0}</p>
|
|
416
|
+
</div>
|
|
417
|
+
`;
|
|
418
|
+
}).join('');
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
} catch (e) {
|
|
422
|
+
showToast(e.message || 'Failed to load stats', 'error');
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
function bindEvents() {
|
|
427
|
+
const refreshBtn = document.getElementById('btn-refresh-stats');
|
|
428
|
+
if (refreshBtn) refreshBtn.onclick = loadStats;
|
|
429
|
+
|
|
430
|
+
const refreshEntriesBtn = document.getElementById('btn-refresh-entries');
|
|
431
|
+
if (refreshEntriesBtn) refreshEntriesBtn.onclick = loadEntries;
|
|
432
|
+
|
|
433
|
+
const applyEntriesBtn = document.getElementById('btn-entries-apply');
|
|
434
|
+
if (applyEntriesBtn) {
|
|
435
|
+
applyEntriesBtn.onclick = () => {
|
|
436
|
+
entriesState.offset = 0;
|
|
437
|
+
loadEntries();
|
|
438
|
+
};
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
const resetEntriesBtn = document.getElementById('btn-entries-reset');
|
|
442
|
+
if (resetEntriesBtn) {
|
|
443
|
+
resetEntriesBtn.onclick = () => {
|
|
444
|
+
document.getElementById('entries-status').value = '';
|
|
445
|
+
document.getElementById('entries-type').value = '';
|
|
446
|
+
document.getElementById('entries-email').value = '';
|
|
447
|
+
document.getElementById('entries-limit').value = '50';
|
|
448
|
+
entriesState.offset = 0;
|
|
449
|
+
loadEntries();
|
|
450
|
+
};
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
const prevEntriesBtn = document.getElementById('btn-entries-prev');
|
|
454
|
+
if (prevEntriesBtn) {
|
|
455
|
+
prevEntriesBtn.onclick = () => {
|
|
456
|
+
entriesState.offset = Math.max(0, entriesState.offset - entriesState.limit);
|
|
457
|
+
loadEntries();
|
|
458
|
+
};
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
const nextEntriesBtn = document.getElementById('btn-entries-next');
|
|
462
|
+
if (nextEntriesBtn) {
|
|
463
|
+
nextEntriesBtn.onclick = () => {
|
|
464
|
+
entriesState.offset = Math.max(0, entriesState.offset + entriesState.limit);
|
|
465
|
+
loadEntries();
|
|
466
|
+
};
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
const copyVisibleBtn = document.getElementById('btn-copy-visible');
|
|
470
|
+
if (copyVisibleBtn) {
|
|
471
|
+
copyVisibleBtn.onclick = () => {
|
|
472
|
+
const emails = (lastEntries || [])
|
|
473
|
+
.map((e) => String(e?.email || '').trim())
|
|
474
|
+
.filter(Boolean);
|
|
475
|
+
if (emails.length === 0) {
|
|
476
|
+
showToast('No visible emails to copy', 'error');
|
|
477
|
+
return;
|
|
478
|
+
}
|
|
479
|
+
copyText(emails.join('\n'));
|
|
480
|
+
};
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
const clearBtn = document.getElementById('btn-sub-clear');
|
|
484
|
+
if (clearBtn) {
|
|
485
|
+
clearBtn.onclick = () => {
|
|
486
|
+
document.getElementById('sub-email').value = '';
|
|
487
|
+
document.getElementById('sub-type').value = '';
|
|
488
|
+
document.getElementById('sub-ref').value = '';
|
|
489
|
+
const out = document.getElementById('subscribe-response');
|
|
490
|
+
if (out) out.textContent = '';
|
|
491
|
+
};
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
const form = document.getElementById('subscribe-form');
|
|
495
|
+
if (form) {
|
|
496
|
+
form.onsubmit = async (event) => {
|
|
497
|
+
event.preventDefault();
|
|
498
|
+
|
|
499
|
+
const email = document.getElementById('sub-email')?.value?.trim();
|
|
500
|
+
const type = document.getElementById('sub-type')?.value;
|
|
501
|
+
const referralSource = document.getElementById('sub-ref')?.value?.trim();
|
|
502
|
+
|
|
503
|
+
if (!email) {
|
|
504
|
+
showToast('Email is required', 'error');
|
|
505
|
+
return;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
const body = { email, type, referralSource };
|
|
509
|
+
|
|
510
|
+
try {
|
|
511
|
+
const res = await fetch(`${API_BASE}${WAITING_LIST_SUBSCRIBE_PATH}`, {
|
|
512
|
+
method: 'POST',
|
|
513
|
+
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
|
|
514
|
+
body: JSON.stringify(body),
|
|
515
|
+
});
|
|
516
|
+
const data = await res.json();
|
|
517
|
+
const out = document.getElementById('subscribe-response');
|
|
518
|
+
if (out) out.textContent = JSON.stringify(data, null, 2);
|
|
519
|
+
|
|
520
|
+
if (!res.ok) {
|
|
521
|
+
showToast(data?.error || 'Subscribe failed', 'error');
|
|
522
|
+
return;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
showToast('Subscribed!', 'success');
|
|
526
|
+
loadStats();
|
|
527
|
+
} catch (e) {
|
|
528
|
+
showToast(e.message || 'Subscribe failed', 'error');
|
|
529
|
+
}
|
|
530
|
+
};
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
bindEvents();
|
|
535
|
+
loadStats();
|
|
536
|
+
loadEntries();
|
|
537
|
+
</script>
|
|
538
|
+
<script>
|
|
539
|
+
window.addEventListener("keydown", (e) => {
|
|
540
|
+
if ((e.ctrlKey || e.metaKey) && e.key === "k") {
|
|
541
|
+
e.preventDefault();
|
|
542
|
+
window.parent.postMessage({ type: "keydown", ctrlK: true }, "*");
|
|
543
|
+
}
|
|
544
|
+
});
|
|
545
|
+
</script>
|
|
546
|
+
</body>
|
|
547
|
+
</html>
|