evaldoc 1.0.0
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/.env.staging.example +19 -0
- package/.firebaserc +6 -0
- package/.github/workflows/auto-merge-to-main.yml +28 -0
- package/.github/workflows/ci.yml +27 -0
- package/.github/workflows/npxhub-publish.yml +170 -0
- package/.prettierignore +4 -0
- package/.prettierrc.json +7 -0
- package/README.md +142 -0
- package/SRS.md +448 -0
- package/apphosting.staging.yaml +48 -0
- package/apphosting.yaml +50 -0
- package/bin/evaldoc.cjs +105 -0
- package/cors.json +8 -0
- package/docs/ARCHITECTURE.md +769 -0
- package/docs/ARCHITECTURE_SUMMARY.md +323 -0
- package/docs/DEPLOYMENT.md +86 -0
- package/docs/DEPLOYMENT_STEPS.md +373 -0
- package/docs/DEPLOY_FIRESTORE_RULES.md +239 -0
- package/docs/DEPLOY_STORAGE_RULES.md +82 -0
- package/docs/DOCTOR_TUTORIAL.md +467 -0
- package/docs/FIREBASE_STORAGE_CORS_FIX.md +131 -0
- package/docs/FIXES_APPLIED.md +128 -0
- package/docs/IMPLEMENTATION.md +498 -0
- package/docs/IMPLEMENTATION_COMPLETE.md +317 -0
- package/docs/LLM-SETUP.md +216 -0
- package/docs/MANUAL_TEST_GUIDE.md +327 -0
- package/docs/PATIENT_GUIDE.md +451 -0
- package/docs/PLATFORM_OVERVIEW.md +194 -0
- package/docs/QUICKSTART.md +252 -0
- package/docs/QUICK_REFERENCE.md +222 -0
- package/docs/READY_TO_TEST.md +383 -0
- package/docs/RUN.md +237 -0
- package/docs/START_TESTING.md +130 -0
- package/docs/STRIPE_CONNECT_SETUP.md +454 -0
- package/docs/SYSTEM_READY.md +345 -0
- package/docs/THREADS_IMPLEMENTATION_COMPLETE.md +431 -0
- package/docs/THREAD_IMPLEMENTATION_GUIDE.md +345 -0
- package/docs/firebasefiles.md +21 -0
- package/docs/homepage.md +1208 -0
- package/docs/payments.md +159 -0
- package/eslint.config.js +76 -0
- package/firebase.json +27 -0
- package/firestore.indexes.json +88 -0
- package/firestore.rules +125 -0
- package/functions/package-lock.json +2884 -0
- package/functions/package.json +20 -0
- package/functions/src/analytics.js +101 -0
- package/functions/src/audit.js +28 -0
- package/functions/src/auth.js +19 -0
- package/functions/src/drafts.js +128 -0
- package/functions/src/feedback.js +164 -0
- package/functions/src/firebase-init.js +29 -0
- package/functions/src/i18n.js +134 -0
- package/functions/src/limits.js +27 -0
- package/functions/src/llm.js +271 -0
- package/functions/src/ner.js +162 -0
- package/functions/src/notifications.js +273 -0
- package/functions/src/orgs.js +127 -0
- package/functions/src/patient-api.js +545 -0
- package/functions/src/payment.js +830 -0
- package/functions/src/safety.js +188 -0
- package/functions/src/stripe-config.js +33 -0
- package/functions/src/topics.js +94 -0
- package/functions/src/triage.js +191 -0
- package/functions/src/utils.js +28 -0
- package/functions/src/validate.js +177 -0
- package/functions/test-firebase.js +36 -0
- package/jest.config.js +18 -0
- package/package.json +52 -0
- package/public/404.html +93 -0
- package/public/app/analytics.html +336 -0
- package/public/app/analytics.js +177 -0
- package/public/app/app.css +1400 -0
- package/public/app/app.js +1754 -0
- package/public/app/config.js +14 -0
- package/public/app/inbox-threads.js +843 -0
- package/public/app/inbox.html +598 -0
- package/public/app/inbox.js +397 -0
- package/public/app/index.html +612 -0
- package/public/assets/logo.jpg +0 -0
- package/public/chat.html +137 -0
- package/public/css/chat.css +870 -0
- package/public/css/landing-enhance.css +298 -0
- package/public/css/styles.css +1198 -0
- package/public/favicon.svg +5 -0
- package/public/find-doctor.html +220 -0
- package/public/index.html +399 -0
- package/public/js/chat-threads.js +513 -0
- package/public/js/chat.js +832 -0
- package/public/js/search.js +41 -0
- package/public/privacy.html +101 -0
- package/public/robots.txt +6 -0
- package/public/site.webmanifest +12 -0
- package/public/terms.html +110 -0
- package/scripts/create-test-provider.js +55 -0
- package/scripts/deploy-staging.sh +51 -0
- package/scripts/fix-provider.js +39 -0
- package/scripts/quick-test.sh +37 -0
- package/scripts/test-chat.sh +54 -0
- package/scripts/test-multi-turn.js +137 -0
- package/scripts/test-ner-simple.js +28 -0
- package/scripts/test-ner.js +60 -0
- package/scripts/test-stripe-connect.js +304 -0
- package/scripts/test-webhook-secret.sh +24 -0
- package/server.js +3864 -0
- package/storage.rules +22 -0
- package/test-api.json +1 -0
- package/tests/emulator/README.md +38 -0
- package/tests/emulator/counters.emulator.test.js +81 -0
- package/tests/integration/api-account-selfserve.test.js +312 -0
- package/tests/integration/api-admin-digest.test.js +202 -0
- package/tests/integration/api-chat-satisfaction.test.js +259 -0
- package/tests/integration/api-doctor-drafts.test.js +236 -0
- package/tests/integration/api-doctor-inbox.test.js +401 -0
- package/tests/integration/api-org.test.js +328 -0
- package/tests/integration/api-payment.test.js +664 -0
- package/tests/integration/api-provider-settings.test.js +475 -0
- package/tests/integration/api-qr.test.js +121 -0
- package/tests/integration/api-security.test.js +241 -0
- package/tests/integration/api-stripe-search.test.js +303 -0
- package/tests/integration/api-webhook-deletion.test.js +231 -0
- package/tests/mocks/firebase-admin.js +220 -0
- package/tests/unit/analytics.test.js +151 -0
- package/tests/unit/audit-bugs.test.js +203 -0
- package/tests/unit/chat-ui-logic.test.js +283 -0
- package/tests/unit/drafts.test.js +104 -0
- package/tests/unit/feedback.test.js +148 -0
- package/tests/unit/i18n.test.js +229 -0
- package/tests/unit/llm-retry.test.js +122 -0
- package/tests/unit/notifications.test.js +155 -0
- package/tests/unit/orgs.test.js +328 -0
- package/tests/unit/payment.test.js +889 -0
- package/tests/unit/safety-edge-cases.test.js +509 -0
- package/tests/unit/safety-srs-compliance.test.js +252 -0
- package/tests/unit/safety.test.js +291 -0
- package/tests/unit/server-helpers.test.js +774 -0
- package/tests/unit/topics-limits.test.js +158 -0
- package/tests/unit/triage.test.js +152 -0
- package/tests/unit/validate.test.js +408 -0
|
@@ -0,0 +1,1754 @@
|
|
|
1
|
+
const TOPICS = [
|
|
2
|
+
"medication_info_safety",
|
|
3
|
+
"appointments_preparation",
|
|
4
|
+
"general_health_info",
|
|
5
|
+
"clinic_services_hours",
|
|
6
|
+
"insurance_billing_faq",
|
|
7
|
+
"post_procedure_guidance"
|
|
8
|
+
];
|
|
9
|
+
|
|
10
|
+
const waitlistPanel = document.getElementById("waitlistPanel");
|
|
11
|
+
const authPanel = document.getElementById("authPanel");
|
|
12
|
+
const onboardingPanel = document.getElementById("onboardingPanel");
|
|
13
|
+
const billingSetupPanel = document.getElementById("billingSetupPanel");
|
|
14
|
+
const dashboardPanel = document.getElementById("dashboardPanel");
|
|
15
|
+
const signOutBtn = document.getElementById("signOutBtn");
|
|
16
|
+
const authError = document.getElementById("authError");
|
|
17
|
+
const onboardingError = document.getElementById("onboardingError");
|
|
18
|
+
|
|
19
|
+
const loginEmail = document.getElementById("loginEmail");
|
|
20
|
+
const loginPassword = document.getElementById("loginPassword");
|
|
21
|
+
const signupEmail = document.getElementById("signupEmail");
|
|
22
|
+
const signupPassword = document.getElementById("signupPassword");
|
|
23
|
+
const loginBtn = document.getElementById("loginBtn");
|
|
24
|
+
const signupBtn = document.getElementById("signupBtn");
|
|
25
|
+
|
|
26
|
+
const onboardingForm = document.getElementById("onboardingForm");
|
|
27
|
+
const handleInput = document.getElementById("handleInput");
|
|
28
|
+
const displayNameInput = document.getElementById("displayNameInput");
|
|
29
|
+
const specialtyInput = document.getElementById("specialtyInput");
|
|
30
|
+
const countryInput = document.getElementById("countryInput");
|
|
31
|
+
const primaryColorInput = document.getElementById("primaryColorInput");
|
|
32
|
+
const secondaryColorInput = document.getElementById("secondaryColorInput");
|
|
33
|
+
const welcomeInput = document.getElementById("welcomeInput");
|
|
34
|
+
const topicsList = document.getElementById("topicsList");
|
|
35
|
+
|
|
36
|
+
const statMonthly = document.getElementById("statMonthly");
|
|
37
|
+
const statEscalations = document.getElementById("statEscalations");
|
|
38
|
+
const statLimit = document.getElementById("statLimit");
|
|
39
|
+
const refreshAnalyticsBtn = document.getElementById("refreshAnalyticsBtn");
|
|
40
|
+
const shareLink = document.getElementById("shareLink");
|
|
41
|
+
const copyLinkBtn = document.getElementById("copyLinkBtn");
|
|
42
|
+
const downloadQRBtn = document.getElementById("downloadQRBtn");
|
|
43
|
+
const previewChatBtn = document.getElementById("previewChatBtn");
|
|
44
|
+
const qrCanvas = document.getElementById("qrCanvas");
|
|
45
|
+
|
|
46
|
+
const topicsManage = document.getElementById("topicsManage");
|
|
47
|
+
const saveTopicsBtn = document.getElementById("saveTopicsBtn");
|
|
48
|
+
const welcomeUpdate = document.getElementById("welcomeUpdate");
|
|
49
|
+
const statusSelect = document.getElementById("statusSelect");
|
|
50
|
+
const saveSettingsBtn = document.getElementById("saveSettingsBtn");
|
|
51
|
+
const conversationsEl = document.getElementById("conversations");
|
|
52
|
+
|
|
53
|
+
let currentProvider = null;
|
|
54
|
+
|
|
55
|
+
function setVisible(element, visible) {
|
|
56
|
+
element.classList.toggle("hidden", !visible);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function setError(element, message) {
|
|
60
|
+
element.textContent = message || "";
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function renderTopicCheckboxes(container, selected = []) {
|
|
64
|
+
container.innerHTML = "";
|
|
65
|
+
TOPICS.forEach((topic) => {
|
|
66
|
+
const label = document.createElement("label");
|
|
67
|
+
label.className = "topic-chip";
|
|
68
|
+
const checkbox = document.createElement("input");
|
|
69
|
+
checkbox.type = "checkbox";
|
|
70
|
+
checkbox.value = topic;
|
|
71
|
+
checkbox.checked = selected.includes(topic);
|
|
72
|
+
checkbox.addEventListener("change", () => {
|
|
73
|
+
label.classList.toggle("topic-active", checkbox.checked);
|
|
74
|
+
});
|
|
75
|
+
if (checkbox.checked) label.classList.add("topic-active");
|
|
76
|
+
label.appendChild(checkbox);
|
|
77
|
+
label.appendChild(document.createTextNode(topic.replace(/_/g, " ")));
|
|
78
|
+
container.appendChild(label);
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function fetchWithAuth(path, options = {}) {
|
|
83
|
+
const user = firebase.auth().currentUser;
|
|
84
|
+
console.log('[FETCH] Current user:', user ? user.email : 'None');
|
|
85
|
+
|
|
86
|
+
if (!user) {
|
|
87
|
+
console.error('[FETCH] ❌ No authenticated user');
|
|
88
|
+
throw new Error('Not authenticated');
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const token = await user.getIdToken();
|
|
92
|
+
console.log('[FETCH] Got ID token, length:', token.length);
|
|
93
|
+
console.log('[FETCH] Making request to:', path);
|
|
94
|
+
|
|
95
|
+
const headers = Object.assign({}, options.headers || {}, {
|
|
96
|
+
Authorization: `Bearer ${token}`
|
|
97
|
+
});
|
|
98
|
+
return fetch(path, { ...options, headers });
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async function loadProvider() {
|
|
102
|
+
const res = await fetchWithAuth("/api/me");
|
|
103
|
+
if (res.status === 404) {
|
|
104
|
+
currentProvider = null;
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
if (!res.ok) {
|
|
108
|
+
throw new Error("Unable to load provider");
|
|
109
|
+
}
|
|
110
|
+
const provider = await res.json();
|
|
111
|
+
currentProvider = provider;
|
|
112
|
+
return provider;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function loadAnalytics() {
|
|
116
|
+
if (!currentProvider) return;
|
|
117
|
+
const statEls = [statMonthly, statEscalations, statLimit];
|
|
118
|
+
var skeletonHtml = '<span class="skeleton-stat"></span>';
|
|
119
|
+
statEls.forEach(el => { if (el) el.innerHTML = skeletonHtml; });
|
|
120
|
+
const refreshBtn = document.getElementById("refreshAnalyticsBtn");
|
|
121
|
+
if (refreshBtn) refreshBtn.disabled = true;
|
|
122
|
+
|
|
123
|
+
try {
|
|
124
|
+
const res = await fetchWithAuth("/api/analytics");
|
|
125
|
+
if (!res.ok) {
|
|
126
|
+
statEls.forEach(el => { if (el) el.innerHTML = '<span style="cursor:pointer;color:var(--accent);" onclick="loadAnalytics()">⟳ Retry</span>'; });
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
const data = await res.json();
|
|
130
|
+
const chats = data.provider.monthlyChatCount || 0;
|
|
131
|
+
const escalations = data.provider.totalEscalationCount || 0;
|
|
132
|
+
statMonthly.textContent = chats;
|
|
133
|
+
statEscalations.textContent = escalations;
|
|
134
|
+
const limitValue = data.provider.monthlyChatLimit || 0;
|
|
135
|
+
statLimit.textContent = limitValue <= 0 ? "∞" : limitValue;
|
|
136
|
+
const rateEl = document.getElementById("statEscalationRate");
|
|
137
|
+
if (rateEl) {
|
|
138
|
+
rateEl.textContent = (data.escalationRate != null ? data.escalationRate : 0) + "%";
|
|
139
|
+
}
|
|
140
|
+
const resRateEl = document.getElementById("statResolutionRate");
|
|
141
|
+
if (resRateEl) {
|
|
142
|
+
resRateEl.textContent = data.resolutionRate != null ? data.resolutionRate + "%" : "—";
|
|
143
|
+
}
|
|
144
|
+
const satEl = document.getElementById("statSatisfaction");
|
|
145
|
+
if (satEl && data.satisfaction) {
|
|
146
|
+
satEl.textContent = data.satisfaction.average
|
|
147
|
+
? `${data.satisfaction.average}/5 (${data.satisfaction.count})`
|
|
148
|
+
: "—";
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const totalConvEl = document.getElementById("statTotalConversations");
|
|
152
|
+
if (totalConvEl) {
|
|
153
|
+
totalConvEl.textContent = data.totalConversations || 0;
|
|
154
|
+
}
|
|
155
|
+
const uniquePatientsEl = document.getElementById("statUniquePatients");
|
|
156
|
+
if (uniquePatientsEl) {
|
|
157
|
+
uniquePatientsEl.textContent = data.uniquePatients || 0;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const topicsListEl = document.getElementById("topTopicsList");
|
|
161
|
+
if (topicsListEl && Array.isArray(data.topTopics) && data.topTopics.length) {
|
|
162
|
+
topicsListEl.innerHTML = data.topTopics.map(t =>
|
|
163
|
+
`<div style="display:flex;justify-content:space-between;padding:0.2rem 0;"><span>${formatTopicName(t.topic)}</span><strong>${t.count}</strong></div>`
|
|
164
|
+
).join("");
|
|
165
|
+
}
|
|
166
|
+
const questionsListEl = document.getElementById("topQuestionsList");
|
|
167
|
+
if (questionsListEl && Array.isArray(data.topQuestions) && data.topQuestions.length) {
|
|
168
|
+
questionsListEl.innerHTML = data.topQuestions.map(q =>
|
|
169
|
+
`<div style="display:flex;justify-content:space-between;padding:0.2rem 0;"><span>${escapeHtml(q.question)}</span><strong>${q.count}</strong></div>`
|
|
170
|
+
).join("");
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const draftEl = document.getElementById("draftMetricsBreakdown");
|
|
174
|
+
if (draftEl && data.draftMetrics) {
|
|
175
|
+
const dm = data.draftMetrics;
|
|
176
|
+
const total = dm.accepted + dm.edited + dm.rejected;
|
|
177
|
+
if (total > 0) {
|
|
178
|
+
const pct = (v) => Math.round((v / total) * 100);
|
|
179
|
+
draftEl.innerHTML = `
|
|
180
|
+
<div style="display:flex;gap:4px;height:8px;border-radius:4px;overflow:hidden;margin-bottom:0.5rem;">
|
|
181
|
+
<div style="flex:${dm.accepted};background:#10b981;" title="Accepted ${pct(dm.accepted)}%"></div>
|
|
182
|
+
<div style="flex:${dm.edited};background:#f59e0b;" title="Edited ${pct(dm.edited)}%"></div>
|
|
183
|
+
<div style="flex:${dm.rejected};background:#ef4444;" title="Rejected ${pct(dm.rejected)}%"></div>
|
|
184
|
+
</div>
|
|
185
|
+
<div style="display:flex;justify-content:space-between;font-size:0.75rem;color:#6b7280;">
|
|
186
|
+
<span>✓ ${dm.accepted} accepted</span>
|
|
187
|
+
<span>✎ ${dm.edited} edited</span>
|
|
188
|
+
<span>✕ ${dm.rejected} rejected</span>
|
|
189
|
+
</div>`;
|
|
190
|
+
} else {
|
|
191
|
+
draftEl.textContent = "No drafts yet";
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const updatedEl = document.getElementById("analyticsLastUpdated");
|
|
196
|
+
if (updatedEl) {
|
|
197
|
+
updatedEl.textContent = "Updated " + new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
|
198
|
+
}
|
|
199
|
+
} catch (err) {
|
|
200
|
+
statEls.forEach(el => { if (el) el.innerHTML = '<span style="cursor:pointer;color:var(--accent);" onclick="loadAnalytics()">⟳ Retry</span>'; });
|
|
201
|
+
} finally {
|
|
202
|
+
if (refreshBtn) refreshBtn.disabled = false;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function formatTopicName(key) {
|
|
207
|
+
return escapeHtml((key || "").replace(/_/g, " ").replace(/\b\w/g, c => c.toUpperCase()));
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function escapeHtml(text) {
|
|
211
|
+
const d = document.createElement("div");
|
|
212
|
+
d.textContent = text;
|
|
213
|
+
return d.innerHTML;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
let conversationOffset = 0;
|
|
217
|
+
const CONV_PAGE_SIZE = 20;
|
|
218
|
+
|
|
219
|
+
async function loadConversations(append) {
|
|
220
|
+
if (!currentProvider) return;
|
|
221
|
+
if (!append) conversationOffset = 0;
|
|
222
|
+
const searchEl = document.getElementById("conversationSearch");
|
|
223
|
+
const filterEl = document.getElementById("conversationFilter");
|
|
224
|
+
const q = searchEl ? searchEl.value.trim() : "";
|
|
225
|
+
const filter = filterEl ? filterEl.value : "";
|
|
226
|
+
let url = `/api/conversations?limit=${CONV_PAGE_SIZE}&offset=${conversationOffset}`;
|
|
227
|
+
if (q) url += `&q=${encodeURIComponent(q)}`;
|
|
228
|
+
if (filter === "escalated") url += `&escalated=true`;
|
|
229
|
+
if (filter === "high-satisfaction") url += `&satisfaction=high`;
|
|
230
|
+
if (filter === "low-satisfaction") url += `&satisfaction=low`;
|
|
231
|
+
if (filter === "unrated") url += `&satisfaction=none`;
|
|
232
|
+
if (!append) conversationsEl.innerHTML = '<div class="loading-skeleton"><div class="skeleton-line"></div><div class="skeleton-line short"></div><div class="skeleton-line"></div></div>';
|
|
233
|
+
const res = await fetchWithAuth(url);
|
|
234
|
+
if (!res.ok) { conversationsEl.innerHTML = '<p style="color:#999;">Failed to load conversations.</p>'; return; }
|
|
235
|
+
const data = await res.json();
|
|
236
|
+
if (!append) conversationsEl.innerHTML = "";
|
|
237
|
+
if (!data.conversations.length && !append) {
|
|
238
|
+
conversationsEl.textContent = "No conversations yet.";
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
data.conversations.forEach((conversation) => {
|
|
242
|
+
const item = document.createElement("div");
|
|
243
|
+
item.className = "conversation-item";
|
|
244
|
+
item.style.cursor = "pointer";
|
|
245
|
+
const contentArea = document.createElement("div");
|
|
246
|
+
contentArea.style.flex = "1";
|
|
247
|
+
contentArea.addEventListener("click", () => viewTranscript(conversation.id));
|
|
248
|
+
const title = document.createElement("div");
|
|
249
|
+
title.textContent = conversation.lastUserMessage || "(no message)";
|
|
250
|
+
const meta = document.createElement("div");
|
|
251
|
+
meta.className = "conversation-meta";
|
|
252
|
+
const tag = conversation.isEscalated ? "⚠ Escalated" : "OK";
|
|
253
|
+
const time = conversation.createdAt ? new Date(conversation.createdAt).toLocaleDateString() : "";
|
|
254
|
+
meta.textContent = `${conversation.topic || "general"} · ${tag} · ${time}`;
|
|
255
|
+
contentArea.appendChild(title);
|
|
256
|
+
contentArea.appendChild(meta);
|
|
257
|
+
item.appendChild(contentArea);
|
|
258
|
+
|
|
259
|
+
const deleteBtn = document.createElement("button");
|
|
260
|
+
deleteBtn.className = "conversation-delete-btn";
|
|
261
|
+
deleteBtn.textContent = "✕";
|
|
262
|
+
deleteBtn.title = "Delete conversation";
|
|
263
|
+
deleteBtn.addEventListener("click", async (e) => {
|
|
264
|
+
e.stopPropagation();
|
|
265
|
+
if (!confirm("Delete this conversation? This cannot be undone.")) return;
|
|
266
|
+
const res = await fetchWithAuth(`/api/conversations/${conversation.id}`, { method: "DELETE" });
|
|
267
|
+
if (res.ok) {
|
|
268
|
+
item.remove();
|
|
269
|
+
} else {
|
|
270
|
+
alert("Failed to delete conversation.");
|
|
271
|
+
}
|
|
272
|
+
});
|
|
273
|
+
item.appendChild(deleteBtn);
|
|
274
|
+
conversationsEl.appendChild(item);
|
|
275
|
+
});
|
|
276
|
+
conversationOffset += data.conversations.length;
|
|
277
|
+
// Remove existing load-more button
|
|
278
|
+
const existing = conversationsEl.parentElement.querySelector(".load-more-btn");
|
|
279
|
+
if (existing) existing.remove();
|
|
280
|
+
if (data.conversations.length >= CONV_PAGE_SIZE) {
|
|
281
|
+
const btn = document.createElement("button");
|
|
282
|
+
btn.className = "load-more-btn";
|
|
283
|
+
btn.textContent = "Load more";
|
|
284
|
+
btn.addEventListener("click", () => loadConversations(true));
|
|
285
|
+
conversationsEl.parentElement.appendChild(btn);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function scrubPII(text) {
|
|
290
|
+
if (!text) return text;
|
|
291
|
+
return text
|
|
292
|
+
.replace(/\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/gi, "[email]")
|
|
293
|
+
.replace(/\b\d{3}[-.\s]?\d{3}[-.\s]?\d{4}\b/g, "[phone]")
|
|
294
|
+
.replace(/\b\d{3}[-\s]?\d{2}[-\s]?\d{4}\b/g, "[ssn]")
|
|
295
|
+
.replace(/\b(?:\d[ -]*?){13,19}\b/g, "[card]");
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
async function viewTranscript(conversationId) {
|
|
299
|
+
const res = await fetchWithAuth(`/api/conversation/${conversationId}`);
|
|
300
|
+
if (!res.ok) return;
|
|
301
|
+
const data = await res.json();
|
|
302
|
+
const modal = document.createElement("div");
|
|
303
|
+
modal.className = "transcript-modal";
|
|
304
|
+
const content = document.createElement("div");
|
|
305
|
+
content.className = "transcript-content";
|
|
306
|
+
const header = document.createElement("div");
|
|
307
|
+
header.className = "transcript-header";
|
|
308
|
+
header.innerHTML = '<h3>Conversation Transcript</h3><button class="transcript-close">×</button>';
|
|
309
|
+
content.appendChild(header);
|
|
310
|
+
const body = document.createElement("div");
|
|
311
|
+
body.className = "transcript-body";
|
|
312
|
+
(data.messages || []).forEach((msg) => {
|
|
313
|
+
const line = document.createElement("div");
|
|
314
|
+
line.className = `transcript-msg transcript-${msg.role}`;
|
|
315
|
+
const label = document.createElement("strong");
|
|
316
|
+
label.textContent = msg.role === "user" ? "Patient" : "AI";
|
|
317
|
+
const text = document.createElement("span");
|
|
318
|
+
text.textContent = " " + scrubPII(msg.content || msg.message || "");
|
|
319
|
+
line.appendChild(label);
|
|
320
|
+
line.appendChild(text);
|
|
321
|
+
if (msg.createdAt) {
|
|
322
|
+
const ts = document.createElement("div");
|
|
323
|
+
ts.className = "transcript-time";
|
|
324
|
+
const d = msg.createdAt._seconds ? new Date(msg.createdAt._seconds * 1000) : new Date(msg.createdAt);
|
|
325
|
+
ts.textContent = d.toLocaleString();
|
|
326
|
+
line.appendChild(ts);
|
|
327
|
+
}
|
|
328
|
+
body.appendChild(line);
|
|
329
|
+
});
|
|
330
|
+
if (!data.messages || !data.messages.length) {
|
|
331
|
+
body.textContent = "No messages in this conversation.";
|
|
332
|
+
}
|
|
333
|
+
content.appendChild(body);
|
|
334
|
+
modal.appendChild(content);
|
|
335
|
+
document.body.appendChild(modal);
|
|
336
|
+
modal.addEventListener("click", (e) => {
|
|
337
|
+
if (e.target === modal || e.target.classList.contains("transcript-close")) modal.remove();
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function updateShareKit(provider) {
|
|
342
|
+
const link = `${window.location.origin}/${provider.handle}`;
|
|
343
|
+
shareLink.textContent = link;
|
|
344
|
+
if (window.QRCode) {
|
|
345
|
+
QRCode.toCanvas(qrCanvas, link, {
|
|
346
|
+
width: 160,
|
|
347
|
+
margin: 2,
|
|
348
|
+
color: {
|
|
349
|
+
dark: '#000000',
|
|
350
|
+
light: '#FFFFFF'
|
|
351
|
+
}
|
|
352
|
+
}, () => {});
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
async function handleOnboardingSubmit(event) {
|
|
357
|
+
event.preventDefault();
|
|
358
|
+
setError(onboardingError, "");
|
|
359
|
+
|
|
360
|
+
// Get certificate file
|
|
361
|
+
const certificateFile = document.getElementById('certificateInput').files[0];
|
|
362
|
+
const licenseNumber = document.getElementById('licenseNumberInput').value.trim();
|
|
363
|
+
|
|
364
|
+
if (!certificateFile) {
|
|
365
|
+
setError(onboardingError, "Please upload your doctor certificate");
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// Validate file size (5MB max)
|
|
370
|
+
if (certificateFile.size > 5 * 1024 * 1024) {
|
|
371
|
+
setError(onboardingError, "Certificate file must be less than 5MB");
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// Show uploading message
|
|
376
|
+
setError(onboardingError, "Uploading certificate...");
|
|
377
|
+
|
|
378
|
+
try {
|
|
379
|
+
// Upload certificate to Firebase Storage
|
|
380
|
+
const user = firebase.auth().currentUser;
|
|
381
|
+
if (!user) {
|
|
382
|
+
setError(onboardingError, "Please wait, authenticating...");
|
|
383
|
+
// Wait a bit for auth to settle
|
|
384
|
+
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
385
|
+
const retryUser = firebase.auth().currentUser;
|
|
386
|
+
if (!retryUser) {
|
|
387
|
+
setError(onboardingError, "Authentication error. Please refresh and try again.");
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// Force refresh the token to ensure it's valid
|
|
393
|
+
await user.getIdToken(true);
|
|
394
|
+
|
|
395
|
+
const storage = firebase.storage();
|
|
396
|
+
const storageRef = storage.ref();
|
|
397
|
+
const certificateRef = storageRef.child(`certificates/${user.uid}/${certificateFile.name}`);
|
|
398
|
+
|
|
399
|
+
await certificateRef.put(certificateFile);
|
|
400
|
+
const certificateUrl = await certificateRef.getDownloadURL();
|
|
401
|
+
|
|
402
|
+
let logoUrl = null;
|
|
403
|
+
let photoUrl = null;
|
|
404
|
+
const logoFile = document.getElementById("logoInput")?.files[0];
|
|
405
|
+
const photoFile = document.getElementById("photoInput")?.files[0];
|
|
406
|
+
|
|
407
|
+
if (logoFile) {
|
|
408
|
+
if (logoFile.size > 2 * 1024 * 1024) { setError(onboardingError, "Logo must be under 2MB"); return; }
|
|
409
|
+
setError(onboardingError, "Uploading logo...");
|
|
410
|
+
const logoRef = storageRef.child(`logos/${user.uid}/${logoFile.name}`);
|
|
411
|
+
await logoRef.put(logoFile);
|
|
412
|
+
logoUrl = await logoRef.getDownloadURL();
|
|
413
|
+
}
|
|
414
|
+
if (photoFile) {
|
|
415
|
+
if (photoFile.size > 2 * 1024 * 1024) { setError(onboardingError, "Photo must be under 2MB"); return; }
|
|
416
|
+
setError(onboardingError, "Uploading photo...");
|
|
417
|
+
const photoRef = storageRef.child(`photos/${user.uid}/${photoFile.name}`);
|
|
418
|
+
await photoRef.put(photoFile);
|
|
419
|
+
photoUrl = await photoRef.getDownloadURL();
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
setError(onboardingError, "Creating provider profile...");
|
|
423
|
+
|
|
424
|
+
const selectedTopics = Array.from(topicsList.querySelectorAll("input[type=checkbox]"))
|
|
425
|
+
.filter((input) => input.checked)
|
|
426
|
+
.map((input) => input.value);
|
|
427
|
+
|
|
428
|
+
const payload = {
|
|
429
|
+
handle: handleInput.value.trim(),
|
|
430
|
+
displayName: displayNameInput.value.trim(),
|
|
431
|
+
specialty: specialtyInput.value.trim(),
|
|
432
|
+
country: countryInput.value.trim(),
|
|
433
|
+
licenseNumber: licenseNumber,
|
|
434
|
+
certificateUrl: certificateUrl,
|
|
435
|
+
certificateFileName: certificateFile.name,
|
|
436
|
+
logoUrl: logoUrl,
|
|
437
|
+
photoUrl: photoUrl,
|
|
438
|
+
brandPrimaryColor: primaryColorInput.value.trim(),
|
|
439
|
+
brandSecondaryColor: secondaryColorInput.value.trim(),
|
|
440
|
+
welcomeMessage: welcomeInput.value.trim(),
|
|
441
|
+
enabledTopics: selectedTopics,
|
|
442
|
+
verificationStatus: 'pending'
|
|
443
|
+
};
|
|
444
|
+
|
|
445
|
+
const res = await fetchWithAuth("/api/provider", {
|
|
446
|
+
method: "POST",
|
|
447
|
+
headers: { "Content-Type": "application/json" },
|
|
448
|
+
body: JSON.stringify(payload)
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
if (!res.ok) {
|
|
452
|
+
const data = await res.json().catch(() => ({}));
|
|
453
|
+
if (data.code === 'WAITLIST_REQUIRED') {
|
|
454
|
+
// Not yet approved — switch back to waitlist panel
|
|
455
|
+
setVisible(onboardingPanel, false);
|
|
456
|
+
setVisible(waitlistPanel, true);
|
|
457
|
+
const msg = document.getElementById('waitlistMessage');
|
|
458
|
+
if (msg) msg.textContent = 'Your account is pending approval. We will notify you at launch.';
|
|
459
|
+
return;
|
|
460
|
+
}
|
|
461
|
+
setError(onboardingError, data.error || "Unable to create provider");
|
|
462
|
+
return;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
const provider = await res.json();
|
|
466
|
+
currentProvider = provider;
|
|
467
|
+
|
|
468
|
+
// After provider creation, move to billing setup
|
|
469
|
+
setVisible(onboardingPanel, false);
|
|
470
|
+
setVisible(billingSetupPanel, true);
|
|
471
|
+
await renderBillingSetup(provider);
|
|
472
|
+
} catch (error) {
|
|
473
|
+
console.error('Error during onboarding:', error);
|
|
474
|
+
setError(onboardingError, "Error uploading certificate: " + error.message);
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
async function renderBillingSetup(provider) {
|
|
479
|
+
currentProvider = provider;
|
|
480
|
+
setVisible(authPanel, false);
|
|
481
|
+
setVisible(onboardingPanel, false);
|
|
482
|
+
setVisible(billingSetupPanel, true);
|
|
483
|
+
setVisible(dashboardPanel, false);
|
|
484
|
+
setVisible(signOutBtn, true);
|
|
485
|
+
|
|
486
|
+
// Check Stripe connection status
|
|
487
|
+
const billingStatus = document.getElementById('billingStatus');
|
|
488
|
+
const stripeNotConnected = document.getElementById('stripeNotConnected');
|
|
489
|
+
const stripeOnboarding = document.getElementById('stripeOnboarding');
|
|
490
|
+
const stripeConnected = document.getElementById('stripeConnected');
|
|
491
|
+
|
|
492
|
+
if (!provider.stripeAccountId) {
|
|
493
|
+
// No Stripe account - show create button
|
|
494
|
+
setVisible(billingStatus, false);
|
|
495
|
+
setVisible(stripeNotConnected, true);
|
|
496
|
+
setVisible(stripeOnboarding, false);
|
|
497
|
+
setVisible(stripeConnected, false);
|
|
498
|
+
} else if (!provider.stripeOnboardingComplete) {
|
|
499
|
+
// Stripe account exists but onboarding not complete
|
|
500
|
+
setVisible(billingStatus, false);
|
|
501
|
+
setVisible(stripeNotConnected, false);
|
|
502
|
+
setVisible(stripeOnboarding, true);
|
|
503
|
+
setVisible(stripeConnected, false);
|
|
504
|
+
} else {
|
|
505
|
+
// Stripe fully connected - show pricing form
|
|
506
|
+
setVisible(billingStatus, false);
|
|
507
|
+
setVisible(stripeNotConnected, false);
|
|
508
|
+
setVisible(stripeOnboarding, false);
|
|
509
|
+
setVisible(stripeConnected, true);
|
|
510
|
+
|
|
511
|
+
// Pre-fill pricing if it exists
|
|
512
|
+
const billingPricingModel = document.getElementById('billingPricingModel');
|
|
513
|
+
const billingSubscriptionPrice = document.getElementById('billingSubscriptionPrice');
|
|
514
|
+
const billingPerChatPrice = document.getElementById('billingPerChatPrice');
|
|
515
|
+
|
|
516
|
+
if (provider.pricingModel) {
|
|
517
|
+
billingPricingModel.value = provider.pricingModel;
|
|
518
|
+
if (provider.pricingModel === 'subscription' && provider.subscriptionPrice) {
|
|
519
|
+
billingSubscriptionPrice.value = provider.subscriptionPrice;
|
|
520
|
+
}
|
|
521
|
+
if (provider.pricingModel === 'per-chat' && provider.perChatPrice) {
|
|
522
|
+
billingPerChatPrice.value = provider.perChatPrice;
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
function showDashboardSkeletons() {
|
|
529
|
+
var skeletonHtml = '<span class="skeleton-stat"></span>';
|
|
530
|
+
var statIds = [
|
|
531
|
+
"statMonthly", "statEscalations", "statLimit", "statEscalationRate",
|
|
532
|
+
"statSatisfaction", "statResolutionRate", "statTotalConversations", "statUniquePatients"
|
|
533
|
+
];
|
|
534
|
+
statIds.forEach(function(id) {
|
|
535
|
+
var el = document.getElementById(id);
|
|
536
|
+
if (el) el.innerHTML = skeletonHtml;
|
|
537
|
+
});
|
|
538
|
+
var convEl = document.getElementById("conversations");
|
|
539
|
+
if (convEl) {
|
|
540
|
+
convEl.innerHTML = '<div class="loading-skeleton">' +
|
|
541
|
+
'<div class="skeleton-line"></div>' +
|
|
542
|
+
'<div class="skeleton-line short"></div>' +
|
|
543
|
+
'<div class="skeleton-line"></div>' +
|
|
544
|
+
'</div>';
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
async function renderDashboard(provider) {
|
|
549
|
+
currentProvider = provider;
|
|
550
|
+
setVisible(authPanel, false);
|
|
551
|
+
setVisible(onboardingPanel, false);
|
|
552
|
+
setVisible(dashboardPanel, true);
|
|
553
|
+
setVisible(signOutBtn, true);
|
|
554
|
+
|
|
555
|
+
// Show loading skeletons immediately while data loads
|
|
556
|
+
showDashboardSkeletons();
|
|
557
|
+
|
|
558
|
+
// Show navigation links
|
|
559
|
+
const navLinks = document.getElementById('navLinks');
|
|
560
|
+
if (navLinks) navLinks.style.display = 'flex';
|
|
561
|
+
|
|
562
|
+
renderTopicCheckboxes(topicsManage, provider.enabledTopics || []);
|
|
563
|
+
welcomeUpdate.value = provider.welcomeMessage || "";
|
|
564
|
+
statusSelect.value = provider.status || "draft";
|
|
565
|
+
|
|
566
|
+
const ci = provider.contactInfo || {};
|
|
567
|
+
const bh = provider.businessHours || {};
|
|
568
|
+
const el = (id) => document.getElementById(id);
|
|
569
|
+
if (el("brandPrimaryUpdate")) el("brandPrimaryUpdate").value = provider.brandPrimaryColor || "";
|
|
570
|
+
if (el("brandSecondaryUpdate")) el("brandSecondaryUpdate").value = provider.brandSecondaryColor || "";
|
|
571
|
+
if (el("contactPhone")) el("contactPhone").value = ci.phone || "";
|
|
572
|
+
if (el("contactEmail")) el("contactEmail").value = ci.email || "";
|
|
573
|
+
if (el("contactAddress")) el("contactAddress").value = ci.address || "";
|
|
574
|
+
if (el("hoursOpen")) el("hoursOpen").value = bh.open || "";
|
|
575
|
+
if (el("hoursClose")) el("hoursClose").value = bh.close || "";
|
|
576
|
+
if (el("hoursSummary")) el("hoursSummary").value = bh.summary || "";
|
|
577
|
+
|
|
578
|
+
const np = provider.notificationPreferences || {};
|
|
579
|
+
if (el("notifEscalation")) el("notifEscalation").checked = np.escalationNotify !== false;
|
|
580
|
+
if (el("notifUnread")) el("notifUnread").checked = np.unreadNotify !== false;
|
|
581
|
+
if (el("notifDigest")) el("notifDigest").checked = np.dailyDigestEnabled === true;
|
|
582
|
+
|
|
583
|
+
updateShareKit(provider);
|
|
584
|
+
updatePreviewCallout(provider);
|
|
585
|
+
loadOnboardingStatus();
|
|
586
|
+
loadVerificationStatus();
|
|
587
|
+
await loadAnalytics();
|
|
588
|
+
await loadConversations();
|
|
589
|
+
|
|
590
|
+
// Load payment data
|
|
591
|
+
await loadPricingConfig();
|
|
592
|
+
await loadRevenueAnalytics();
|
|
593
|
+
loadNotifications();
|
|
594
|
+
updatePlanCard(provider);
|
|
595
|
+
loadSafetyEvents();
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
function updatePlanCard(provider) {
|
|
599
|
+
const plan = provider.plan || "free";
|
|
600
|
+
const limit = provider.monthlyChatLimit || 50;
|
|
601
|
+
const used = provider.monthlyChatCount || 0;
|
|
602
|
+
const pct = limit > 0 ? Math.min((used / limit) * 100, 100) : 0;
|
|
603
|
+
|
|
604
|
+
const badge = document.getElementById("planBadge");
|
|
605
|
+
const bar = document.getElementById("usageBar");
|
|
606
|
+
const text = document.getElementById("usageText");
|
|
607
|
+
|
|
608
|
+
if (badge) {
|
|
609
|
+
badge.textContent = plan.charAt(0).toUpperCase() + plan.slice(1);
|
|
610
|
+
badge.className = "plan-badge plan-" + plan;
|
|
611
|
+
}
|
|
612
|
+
if (bar) {
|
|
613
|
+
bar.style.width = pct + "%";
|
|
614
|
+
bar.className = "usage-bar" + (pct >= 90 ? " danger" : pct >= 70 ? " warn" : "");
|
|
615
|
+
}
|
|
616
|
+
if (text) {
|
|
617
|
+
text.textContent = limit >= 999999
|
|
618
|
+
? `${used} chats this month (unlimited)`
|
|
619
|
+
: `${used} / ${limit} chats this month`;
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
const warningEl = document.getElementById("usageWarning");
|
|
623
|
+
if (warningEl && limit < 999999) {
|
|
624
|
+
if (pct >= 100) {
|
|
625
|
+
warningEl.className = "usage-warning danger";
|
|
626
|
+
warningEl.textContent = "Monthly limit reached. Upgrade to Pro for unlimited chats.";
|
|
627
|
+
warningEl.style.display = "block";
|
|
628
|
+
} else if (pct >= 90) {
|
|
629
|
+
warningEl.className = "usage-warning danger";
|
|
630
|
+
warningEl.textContent = `You've used ${Math.round(pct)}% of your monthly limit (${used}/${limit}).`;
|
|
631
|
+
warningEl.style.display = "block";
|
|
632
|
+
} else if (pct >= 70) {
|
|
633
|
+
warningEl.className = "usage-warning warn";
|
|
634
|
+
warningEl.textContent = `Approaching monthly limit: ${used}/${limit} chats used.`;
|
|
635
|
+
warningEl.style.display = "block";
|
|
636
|
+
} else {
|
|
637
|
+
warningEl.style.display = "none";
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
// Verification status
|
|
642
|
+
const verBadge = document.getElementById("verificationBadge");
|
|
643
|
+
if (verBadge) {
|
|
644
|
+
const vs = provider.verificationStatus || "pending";
|
|
645
|
+
const labels = { pending: "Verification Pending", verified: "Verified", rejected: "Verification Rejected" };
|
|
646
|
+
verBadge.textContent = labels[vs] || labels.pending;
|
|
647
|
+
verBadge.className = "verification-badge verification-" + vs;
|
|
648
|
+
verBadge.style.display = "inline-block";
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
// Onboarding checklist — guided activation card at top of dashboard.
|
|
653
|
+
async function loadOnboardingStatus() {
|
|
654
|
+
if (!currentProvider) return;
|
|
655
|
+
const card = document.getElementById("onboardingChecklistCard");
|
|
656
|
+
if (!card) return;
|
|
657
|
+
try {
|
|
658
|
+
const res = await fetchWithAuth("/api/provider/onboarding-status");
|
|
659
|
+
if (!res.ok) { setVisible(card, false); return; }
|
|
660
|
+
const data = await res.json();
|
|
661
|
+
|
|
662
|
+
if (data.isComplete) {
|
|
663
|
+
setVisible(card, false);
|
|
664
|
+
return;
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
const percent = data.percentComplete || 0;
|
|
668
|
+
const percentEl = document.getElementById("onboardingPercent");
|
|
669
|
+
const barEl = document.getElementById("onboardingProgressBar");
|
|
670
|
+
if (percentEl) percentEl.textContent = percent + "%";
|
|
671
|
+
if (barEl) barEl.style.width = percent + "%";
|
|
672
|
+
|
|
673
|
+
const nextEl = document.getElementById("onboardingNext");
|
|
674
|
+
if (nextEl) {
|
|
675
|
+
if (data.nextStep && data.nextStep.label) {
|
|
676
|
+
nextEl.textContent = "Next: " + data.nextStep.label;
|
|
677
|
+
setVisible(nextEl, true);
|
|
678
|
+
} else {
|
|
679
|
+
setVisible(nextEl, false);
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
const stepsEl = document.getElementById("onboardingSteps");
|
|
684
|
+
if (stepsEl) {
|
|
685
|
+
stepsEl.innerHTML = (data.steps || []).map((s) => {
|
|
686
|
+
const cls = s.done ? "done" : "todo";
|
|
687
|
+
const mark = s.done ? "✓" : "○";
|
|
688
|
+
const optional = s.optional ? '<span class="optional-tag">optional</span>' : "";
|
|
689
|
+
return `<li class="onboarding-step ${cls}"><span class="check">${mark}</span><span>${escapeHtml(s.label)}</span>${optional}</li>`;
|
|
690
|
+
}).join("");
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
setVisible(card, true);
|
|
694
|
+
} catch (e) {
|
|
695
|
+
setVisible(card, false);
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
// Verification status banner — clear messaging about review state.
|
|
700
|
+
async function loadVerificationStatus() {
|
|
701
|
+
if (!currentProvider) return;
|
|
702
|
+
const banner = document.getElementById("verificationBanner");
|
|
703
|
+
if (!banner) return;
|
|
704
|
+
try {
|
|
705
|
+
const res = await fetchWithAuth("/api/provider/verification-status");
|
|
706
|
+
if (!res.ok) { setVisible(banner, false); return; }
|
|
707
|
+
const data = await res.json();
|
|
708
|
+
const status = data.verificationStatus || "pending";
|
|
709
|
+
|
|
710
|
+
let html = "";
|
|
711
|
+
let title = "";
|
|
712
|
+
if (status === "verified") {
|
|
713
|
+
title = "✓ Credentials verified";
|
|
714
|
+
} else if (status === "rejected") {
|
|
715
|
+
title = "Verification needs attention";
|
|
716
|
+
} else {
|
|
717
|
+
title = "Verification in progress";
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
html += `<h3>${title}</h3>`;
|
|
721
|
+
html += `<p class="verification-message">${escapeHtml(data.message || "")}</p>`;
|
|
722
|
+
|
|
723
|
+
if (status === "pending") {
|
|
724
|
+
html += `<p class="verification-detail">Reviews complete within ${escapeHtml(String(data.slaHours))} hours. Your preview link works right now while you wait.</p>`;
|
|
725
|
+
} else if (status === "rejected") {
|
|
726
|
+
if (data.rejectionReason) {
|
|
727
|
+
html += `<div class="verification-reason">Reason: ${escapeHtml(data.rejectionReason)}</div>`;
|
|
728
|
+
}
|
|
729
|
+
if (data.canResubmit) {
|
|
730
|
+
html += `<button id="resubmitVerificationBtn" class="secondary">Resubmit credentials</button>`;
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
banner.innerHTML = html;
|
|
735
|
+
banner.className = "card verification-banner is-" + status;
|
|
736
|
+
setVisible(banner, true);
|
|
737
|
+
|
|
738
|
+
const resubmitBtn = document.getElementById("resubmitVerificationBtn");
|
|
739
|
+
if (resubmitBtn) {
|
|
740
|
+
resubmitBtn.addEventListener("click", () => {
|
|
741
|
+
showToast("Update your credentials in the onboarding form, then resubmit.");
|
|
742
|
+
const certInput = document.getElementById("certificateInput");
|
|
743
|
+
if (certInput) certInput.scrollIntoView({ behavior: "smooth" });
|
|
744
|
+
});
|
|
745
|
+
}
|
|
746
|
+
} catch (e) {
|
|
747
|
+
setVisible(banner, false);
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
// Preview link callout — prominent when provider is in 'preview' status.
|
|
752
|
+
function updatePreviewCallout(provider) {
|
|
753
|
+
const callout = document.getElementById("previewCallout");
|
|
754
|
+
if (!callout || !provider) return;
|
|
755
|
+
if (provider.status === "preview") {
|
|
756
|
+
const link = `${window.location.origin}/${provider.handle}`;
|
|
757
|
+
const linkEl = document.getElementById("previewCalloutLink");
|
|
758
|
+
if (linkEl) linkEl.textContent = link;
|
|
759
|
+
setVisible(callout, true);
|
|
760
|
+
} else {
|
|
761
|
+
setVisible(callout, false);
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
async function checkOnboarding() {
|
|
766
|
+
const user = firebase.auth().currentUser;
|
|
767
|
+
|
|
768
|
+
// Hide auth panel and show sign out button
|
|
769
|
+
setVisible(authPanel, false);
|
|
770
|
+
setVisible(signOutBtn, true);
|
|
771
|
+
|
|
772
|
+
try {
|
|
773
|
+
const provider = await loadProvider();
|
|
774
|
+
if (!provider) {
|
|
775
|
+
// No provider profile exists - show onboarding
|
|
776
|
+
console.log('No provider found, showing onboarding form');
|
|
777
|
+
setVisible(onboardingPanel, true);
|
|
778
|
+
setVisible(billingSetupPanel, false);
|
|
779
|
+
setVisible(dashboardPanel, false);
|
|
780
|
+
renderTopicCheckboxes(topicsList, TOPICS);
|
|
781
|
+
return;
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
// Provider exists - check if billing is set up
|
|
785
|
+
console.log('Provider found:', provider.handle || provider.displayName);
|
|
786
|
+
console.log('Billing status:', {
|
|
787
|
+
stripeAccountId: provider.stripeAccountId || 'not set',
|
|
788
|
+
stripeOnboardingComplete: provider.stripeOnboardingComplete || false,
|
|
789
|
+
pricingModel: provider.pricingModel || 'not set'
|
|
790
|
+
});
|
|
791
|
+
|
|
792
|
+
// Billing is OPTIONAL and skippable — doctors reach value (a working
|
|
793
|
+
// preview link + dashboard) immediately. Billing is only forced when the
|
|
794
|
+
// doctor has explicitly chosen a PAID pricing model but not finished Stripe.
|
|
795
|
+
const needsStripe = provider.pricingModel && provider.pricingModel !== 'free';
|
|
796
|
+
const stripeConfigured = provider.stripeAccountId && provider.stripeOnboardingComplete;
|
|
797
|
+
const needsBillingSetup = needsStripe && !stripeConfigured;
|
|
798
|
+
|
|
799
|
+
if (needsBillingSetup) {
|
|
800
|
+
console.log('Paid plan chosen but Stripe incomplete, showing billing setup');
|
|
801
|
+
setVisible(onboardingPanel, false);
|
|
802
|
+
setVisible(billingSetupPanel, true);
|
|
803
|
+
setVisible(dashboardPanel, false);
|
|
804
|
+
await renderBillingSetup(provider);
|
|
805
|
+
return;
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
// Free or fully-configured — go straight to the dashboard.
|
|
809
|
+
console.log('Showing dashboard');
|
|
810
|
+
await renderDashboard(provider);
|
|
811
|
+
} catch (err) {
|
|
812
|
+
console.log('No provider found (expected for new users) - showing onboarding');
|
|
813
|
+
console.log('Error details:', err.message);
|
|
814
|
+
// Show onboarding on error (likely 404 = no provider)
|
|
815
|
+
setVisible(onboardingPanel, true);
|
|
816
|
+
setVisible(billingSetupPanel, false);
|
|
817
|
+
setVisible(dashboardPanel, false);
|
|
818
|
+
renderTopicCheckboxes(topicsList, TOPICS);
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
async function checkLaunchStatus() {
|
|
823
|
+
try {
|
|
824
|
+
const res = await fetch('/api/launch-status');
|
|
825
|
+
const data = await res.json();
|
|
826
|
+
return data.isOpen;
|
|
827
|
+
} catch {
|
|
828
|
+
return true; // fail open
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
async function initAuth() {
|
|
833
|
+
const config = window.EVALDOC_FIREBASE_CONFIG || {};
|
|
834
|
+
if (!config.apiKey) {
|
|
835
|
+
setError(authError, "Missing Firebase config. Update /app/config.js.");
|
|
836
|
+
return;
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
firebase.initializeApp(config);
|
|
840
|
+
const auth = firebase.auth();
|
|
841
|
+
|
|
842
|
+
const isOpen = await checkLaunchStatus();
|
|
843
|
+
|
|
844
|
+
auth.onAuthStateChanged((user) => {
|
|
845
|
+
if (!user) {
|
|
846
|
+
console.log('❌ No user logged in');
|
|
847
|
+
if (!isOpen) {
|
|
848
|
+
// Pre-launch: show waitlist instead of auth
|
|
849
|
+
setVisible(waitlistPanel, true);
|
|
850
|
+
setVisible(authPanel, false);
|
|
851
|
+
} else {
|
|
852
|
+
setVisible(waitlistPanel, false);
|
|
853
|
+
setVisible(authPanel, true);
|
|
854
|
+
}
|
|
855
|
+
setVisible(onboardingPanel, false);
|
|
856
|
+
setVisible(dashboardPanel, false);
|
|
857
|
+
setVisible(signOutBtn, false);
|
|
858
|
+
return;
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
// User is logged in - show details
|
|
862
|
+
console.log('✅ USER LOGGED IN:');
|
|
863
|
+
console.log(' Email:', user.email);
|
|
864
|
+
console.log(' UID:', user.uid);
|
|
865
|
+
console.log(' Display Name:', user.displayName || 'Not set');
|
|
866
|
+
console.log(' Email Verified:', user.emailVerified);
|
|
867
|
+
|
|
868
|
+
setVisible(waitlistPanel, false);
|
|
869
|
+
checkOnboarding();
|
|
870
|
+
});
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
// Map raw Firebase auth error codes to friendly, non-leaky messages.
|
|
874
|
+
function friendlyAuthError(err) {
|
|
875
|
+
const code = (err && err.code) || "";
|
|
876
|
+
const messages = {
|
|
877
|
+
"auth/invalid-email": "That email address doesn't look right.",
|
|
878
|
+
"auth/user-not-found": "Incorrect email or password.",
|
|
879
|
+
"auth/wrong-password": "Incorrect email or password.",
|
|
880
|
+
"auth/invalid-credential": "Incorrect email or password.",
|
|
881
|
+
"auth/invalid-login-credentials": "Incorrect email or password.",
|
|
882
|
+
"auth/email-already-in-use": "An account with that email already exists — try signing in.",
|
|
883
|
+
"auth/weak-password": "Password must be at least 6 characters.",
|
|
884
|
+
"auth/too-many-requests": "Too many attempts. Please wait a moment and try again.",
|
|
885
|
+
"auth/network-request-failed": "Network error. Check your connection and try again.",
|
|
886
|
+
"auth/user-disabled": "This account has been disabled. Contact support.",
|
|
887
|
+
"auth/operation-not-allowed": "Email sign-in is not enabled. Contact support."
|
|
888
|
+
};
|
|
889
|
+
return messages[code] || "Something went wrong. Please try again.";
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
893
|
+
|
|
894
|
+
async function withButtonBusy(button, busyLabel, fn) {
|
|
895
|
+
const original = button.textContent;
|
|
896
|
+
button.disabled = true;
|
|
897
|
+
button.textContent = busyLabel;
|
|
898
|
+
try {
|
|
899
|
+
await fn();
|
|
900
|
+
} finally {
|
|
901
|
+
button.disabled = false;
|
|
902
|
+
button.textContent = original;
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
const loginForm = document.getElementById("loginForm");
|
|
907
|
+
const signupForm = document.getElementById("signupForm");
|
|
908
|
+
const forgotPasswordLink = document.getElementById("forgotPasswordLink");
|
|
909
|
+
|
|
910
|
+
loginForm.addEventListener("submit", async (event) => {
|
|
911
|
+
event.preventDefault();
|
|
912
|
+
setError(authError, "");
|
|
913
|
+
const email = loginEmail.value.trim();
|
|
914
|
+
const password = loginPassword.value;
|
|
915
|
+
if (!email || !password) {
|
|
916
|
+
setError(authError, "Enter your email and password.");
|
|
917
|
+
return;
|
|
918
|
+
}
|
|
919
|
+
await withButtonBusy(loginBtn, "Signing in…", async () => {
|
|
920
|
+
try {
|
|
921
|
+
await firebase.auth().signInWithEmailAndPassword(email, password);
|
|
922
|
+
} catch (err) {
|
|
923
|
+
setError(authError, friendlyAuthError(err));
|
|
924
|
+
}
|
|
925
|
+
});
|
|
926
|
+
});
|
|
927
|
+
|
|
928
|
+
signupForm.addEventListener("submit", async (event) => {
|
|
929
|
+
event.preventDefault();
|
|
930
|
+
setError(authError, "");
|
|
931
|
+
const email = signupEmail.value.trim();
|
|
932
|
+
const password = signupPassword.value;
|
|
933
|
+
if (!EMAIL_RE.test(email)) {
|
|
934
|
+
setError(authError, "Please enter a valid email address.");
|
|
935
|
+
return;
|
|
936
|
+
}
|
|
937
|
+
if (password.length < 6) {
|
|
938
|
+
setError(authError, "Password must be at least 6 characters.");
|
|
939
|
+
return;
|
|
940
|
+
}
|
|
941
|
+
await withButtonBusy(signupBtn, "Creating…", async () => {
|
|
942
|
+
try {
|
|
943
|
+
await firebase.auth().createUserWithEmailAndPassword(email, password);
|
|
944
|
+
} catch (err) {
|
|
945
|
+
setError(authError, friendlyAuthError(err));
|
|
946
|
+
}
|
|
947
|
+
});
|
|
948
|
+
});
|
|
949
|
+
|
|
950
|
+
if (forgotPasswordLink) {
|
|
951
|
+
forgotPasswordLink.addEventListener("click", async (event) => {
|
|
952
|
+
event.preventDefault();
|
|
953
|
+
setError(authError, "");
|
|
954
|
+
const email = loginEmail.value.trim();
|
|
955
|
+
if (!EMAIL_RE.test(email)) {
|
|
956
|
+
setError(authError, "Enter your email above, then click Forgot password.");
|
|
957
|
+
return;
|
|
958
|
+
}
|
|
959
|
+
try {
|
|
960
|
+
await firebase.auth().sendPasswordResetEmail(email);
|
|
961
|
+
setError(authError, "Password reset email sent — check your inbox.");
|
|
962
|
+
} catch (err) {
|
|
963
|
+
setError(authError, friendlyAuthError(err));
|
|
964
|
+
}
|
|
965
|
+
});
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
signOutBtn.addEventListener("click", async () => {
|
|
969
|
+
await firebase.auth().signOut();
|
|
970
|
+
});
|
|
971
|
+
|
|
972
|
+
onboardingForm.addEventListener("submit", handleOnboardingSubmit);
|
|
973
|
+
|
|
974
|
+
saveTopicsBtn.addEventListener("click", async () => {
|
|
975
|
+
const selectedTopics = Array.from(topicsManage.querySelectorAll("input[type=checkbox]"))
|
|
976
|
+
.filter((input) => input.checked)
|
|
977
|
+
.map((input) => input.value);
|
|
978
|
+
|
|
979
|
+
await fetchWithAuth("/api/provider/update", {
|
|
980
|
+
method: "POST",
|
|
981
|
+
headers: { "Content-Type": "application/json" },
|
|
982
|
+
body: JSON.stringify({ enabledTopics: selectedTopics })
|
|
983
|
+
});
|
|
984
|
+
const provider = await loadProvider();
|
|
985
|
+
if (provider) {
|
|
986
|
+
renderTopicCheckboxes(topicsManage, provider.enabledTopics || []);
|
|
987
|
+
updateShareKit(provider);
|
|
988
|
+
}
|
|
989
|
+
});
|
|
990
|
+
|
|
991
|
+
saveSettingsBtn.addEventListener("click", async () => {
|
|
992
|
+
const g = (id) => (document.getElementById(id) || {}).value || "";
|
|
993
|
+
const payload = {
|
|
994
|
+
welcomeMessage: welcomeUpdate.value.trim(),
|
|
995
|
+
status: statusSelect.value,
|
|
996
|
+
brandPrimaryColor: g("brandPrimaryUpdate").trim() || undefined,
|
|
997
|
+
brandSecondaryColor: g("brandSecondaryUpdate").trim() || undefined,
|
|
998
|
+
contactInfo: {
|
|
999
|
+
phone: g("contactPhone").trim(),
|
|
1000
|
+
email: g("contactEmail").trim(),
|
|
1001
|
+
address: g("contactAddress").trim()
|
|
1002
|
+
},
|
|
1003
|
+
businessHours: {
|
|
1004
|
+
open: g("hoursOpen"),
|
|
1005
|
+
close: g("hoursClose"),
|
|
1006
|
+
summary: g("hoursSummary").trim()
|
|
1007
|
+
}
|
|
1008
|
+
};
|
|
1009
|
+
const res = await fetchWithAuth("/api/provider/update", {
|
|
1010
|
+
method: "POST",
|
|
1011
|
+
headers: { "Content-Type": "application/json" },
|
|
1012
|
+
body: JSON.stringify(payload)
|
|
1013
|
+
});
|
|
1014
|
+
// Save notification preferences in parallel
|
|
1015
|
+
const notifPayload = {
|
|
1016
|
+
escalationNotify: document.getElementById("notifEscalation")?.checked ?? true,
|
|
1017
|
+
unreadNotify: document.getElementById("notifUnread")?.checked ?? true,
|
|
1018
|
+
dailyDigestEnabled: document.getElementById("notifDigest")?.checked ?? false
|
|
1019
|
+
};
|
|
1020
|
+
fetchWithAuth("/api/doctor/notifications/preferences", {
|
|
1021
|
+
method: "POST",
|
|
1022
|
+
headers: { "Content-Type": "application/json" },
|
|
1023
|
+
body: JSON.stringify(notifPayload)
|
|
1024
|
+
}).catch(() => {});
|
|
1025
|
+
|
|
1026
|
+
if (res.ok) {
|
|
1027
|
+
showToast("Settings saved");
|
|
1028
|
+
} else {
|
|
1029
|
+
const errData = await res.json().catch(() => ({}));
|
|
1030
|
+
if (errData.code === "VERIFICATION_REQUIRED") {
|
|
1031
|
+
showToast("Cannot publish: medical credentials must be verified first", true);
|
|
1032
|
+
} else {
|
|
1033
|
+
showToast("Failed to save settings", true);
|
|
1034
|
+
}
|
|
1035
|
+
}
|
|
1036
|
+
const provider = await loadProvider();
|
|
1037
|
+
if (provider) {
|
|
1038
|
+
welcomeUpdate.value = provider.welcomeMessage || "";
|
|
1039
|
+
statusSelect.value = provider.status || "draft";
|
|
1040
|
+
updateShareKit(provider);
|
|
1041
|
+
}
|
|
1042
|
+
});
|
|
1043
|
+
|
|
1044
|
+
function showToast(text, isError) {
|
|
1045
|
+
const toast = document.createElement("div");
|
|
1046
|
+
toast.className = "toast" + (isError ? " toast-error" : "");
|
|
1047
|
+
toast.textContent = text;
|
|
1048
|
+
document.body.appendChild(toast);
|
|
1049
|
+
setTimeout(() => { toast.classList.add("toast-visible"); }, 10);
|
|
1050
|
+
setTimeout(() => { toast.remove(); }, 2500);
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
refreshAnalyticsBtn.addEventListener("click", async () => {
|
|
1054
|
+
await loadAnalytics();
|
|
1055
|
+
await loadConversations();
|
|
1056
|
+
});
|
|
1057
|
+
|
|
1058
|
+
copyLinkBtn.addEventListener("click", async () => {
|
|
1059
|
+
if (!currentProvider) return;
|
|
1060
|
+
const link = `${window.location.origin}/${currentProvider.handle}`;
|
|
1061
|
+
try {
|
|
1062
|
+
await navigator.clipboard.writeText(link);
|
|
1063
|
+
const original = copyLinkBtn.textContent;
|
|
1064
|
+
copyLinkBtn.textContent = "Copied!";
|
|
1065
|
+
setTimeout(() => { copyLinkBtn.textContent = original; }, 2000);
|
|
1066
|
+
} catch (err) {
|
|
1067
|
+
prompt("Copy this link:", link);
|
|
1068
|
+
}
|
|
1069
|
+
});
|
|
1070
|
+
|
|
1071
|
+
const previewCopyBtn = document.getElementById("previewCopyBtn");
|
|
1072
|
+
if (previewCopyBtn) {
|
|
1073
|
+
previewCopyBtn.addEventListener("click", async () => {
|
|
1074
|
+
if (!currentProvider) return;
|
|
1075
|
+
const link = `${window.location.origin}/${currentProvider.handle}`;
|
|
1076
|
+
try {
|
|
1077
|
+
await navigator.clipboard.writeText(link);
|
|
1078
|
+
const original = previewCopyBtn.textContent;
|
|
1079
|
+
previewCopyBtn.textContent = "Copied!";
|
|
1080
|
+
setTimeout(() => { previewCopyBtn.textContent = original; }, 2000);
|
|
1081
|
+
} catch (err) {
|
|
1082
|
+
prompt("Copy this link:", link);
|
|
1083
|
+
}
|
|
1084
|
+
});
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
downloadQRBtn.addEventListener("click", () => {
|
|
1088
|
+
if (!currentProvider) return;
|
|
1089
|
+
|
|
1090
|
+
const link = `${window.location.origin}/${currentProvider.handle}`;
|
|
1091
|
+
|
|
1092
|
+
function downloadBlob(blob) {
|
|
1093
|
+
const url = URL.createObjectURL(blob);
|
|
1094
|
+
const a = document.createElement('a');
|
|
1095
|
+
a.href = url;
|
|
1096
|
+
a.download = `${currentProvider.handle}-qr-code.png`;
|
|
1097
|
+
document.body.appendChild(a);
|
|
1098
|
+
a.click();
|
|
1099
|
+
document.body.removeChild(a);
|
|
1100
|
+
URL.revokeObjectURL(url);
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
if (window.QRCode) {
|
|
1104
|
+
const downloadCanvas = document.createElement('canvas');
|
|
1105
|
+
QRCode.toCanvas(downloadCanvas, link, {
|
|
1106
|
+
width: 512, margin: 2,
|
|
1107
|
+
color: { dark: '#000000', light: '#FFFFFF' }
|
|
1108
|
+
}, (error) => {
|
|
1109
|
+
if (error) {
|
|
1110
|
+
console.error('QR code generation error:', error);
|
|
1111
|
+
return;
|
|
1112
|
+
}
|
|
1113
|
+
downloadCanvas.toBlob(downloadBlob);
|
|
1114
|
+
});
|
|
1115
|
+
} else {
|
|
1116
|
+
fetch(`/api/qr/${currentProvider.handle}`)
|
|
1117
|
+
.then(r => r.blob())
|
|
1118
|
+
.then(downloadBlob)
|
|
1119
|
+
.catch(err => console.error('QR download failed:', err));
|
|
1120
|
+
}
|
|
1121
|
+
});
|
|
1122
|
+
|
|
1123
|
+
if (previewChatBtn) {
|
|
1124
|
+
previewChatBtn.addEventListener("click", () => {
|
|
1125
|
+
if (!currentProvider) return;
|
|
1126
|
+
window.open(`/${currentProvider.handle}`, "_blank");
|
|
1127
|
+
});
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
// Safety events
|
|
1131
|
+
async function loadSafetyEvents() {
|
|
1132
|
+
const el = document.getElementById("safetyEventsList");
|
|
1133
|
+
if (!el || !currentProvider) return;
|
|
1134
|
+
try {
|
|
1135
|
+
const res = await fetchWithAuth("/api/safety-events?limit=10");
|
|
1136
|
+
if (!res.ok) { el.textContent = "Failed to load."; return; }
|
|
1137
|
+
const data = await res.json();
|
|
1138
|
+
if (!data.events || !data.events.length) {
|
|
1139
|
+
el.textContent = "No safety events recorded.";
|
|
1140
|
+
return;
|
|
1141
|
+
}
|
|
1142
|
+
el.innerHTML = data.events.map(ev => {
|
|
1143
|
+
const d = ev.createdAt?._seconds ? new Date(ev.createdAt._seconds * 1000).toLocaleString() : "";
|
|
1144
|
+
const badge = ev.eventType === "escalated" ? "danger" : ev.eventType === "blocked_input" ? "warning" : "warning";
|
|
1145
|
+
return `<div style="padding:0.6rem 0;border-bottom:1px solid var(--gray-200);display:flex;justify-content:space-between;align-items:center;">
|
|
1146
|
+
<div>
|
|
1147
|
+
<span class="pill" style="background:var(--${badge});color:#fff;font-size:0.75rem;padding:0.2rem 0.5rem;border-radius:4px;">${escapeHtml(ev.eventType)}</span>
|
|
1148
|
+
<span style="margin-left:0.5rem;">${escapeHtml(ev.details?.reason || ev.details?.pattern || "")}</span>
|
|
1149
|
+
</div>
|
|
1150
|
+
<span style="font-size:0.75rem;color:var(--gray-400);">${d}</span>
|
|
1151
|
+
</div>`;
|
|
1152
|
+
}).join("");
|
|
1153
|
+
} catch (e) {
|
|
1154
|
+
el.textContent = "Error loading safety events.";
|
|
1155
|
+
}
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
document.getElementById("refreshSafetyBtn")?.addEventListener("click", loadSafetyEvents);
|
|
1159
|
+
|
|
1160
|
+
// Export conversations as CSV
|
|
1161
|
+
document.getElementById("exportConversationsBtn")?.addEventListener("click", async () => {
|
|
1162
|
+
if (!currentProvider) return;
|
|
1163
|
+
const res = await fetchWithAuth("/api/conversations?limit=500");
|
|
1164
|
+
if (!res.ok) { showToast("Export failed", true); return; }
|
|
1165
|
+
const data = await res.json();
|
|
1166
|
+
const rows = [["Date", "Topic", "Escalated", "Satisfaction", "Last Message"]];
|
|
1167
|
+
(data.conversations || []).forEach(c => {
|
|
1168
|
+
const date = c.createdAt?._seconds ? new Date(c.createdAt._seconds * 1000).toISOString() : "";
|
|
1169
|
+
rows.push([
|
|
1170
|
+
date,
|
|
1171
|
+
c.topic || "",
|
|
1172
|
+
c.isEscalated ? "Yes" : "No",
|
|
1173
|
+
c.satisfaction || "",
|
|
1174
|
+
(c.lastUserMessage || "").replace(/"/g, '""')
|
|
1175
|
+
]);
|
|
1176
|
+
});
|
|
1177
|
+
const csv = rows.map(r => r.map(v => `"${v}"`).join(",")).join("\n");
|
|
1178
|
+
const blob = new Blob([csv], { type: "text/csv" });
|
|
1179
|
+
const url = URL.createObjectURL(blob);
|
|
1180
|
+
const a = document.createElement("a");
|
|
1181
|
+
a.href = url;
|
|
1182
|
+
a.download = `conversations-${new Date().toISOString().slice(0, 10)}.csv`;
|
|
1183
|
+
a.click();
|
|
1184
|
+
URL.revokeObjectURL(url);
|
|
1185
|
+
showToast("Exported!");
|
|
1186
|
+
});
|
|
1187
|
+
|
|
1188
|
+
// Share copy templates
|
|
1189
|
+
function copyText(text) {
|
|
1190
|
+
navigator.clipboard.writeText(text).then(() => showToast("Copied!")).catch(() => {});
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
document.getElementById("copySocialTemplate")?.addEventListener("click", () => {
|
|
1194
|
+
if (!currentProvider) return;
|
|
1195
|
+
const link = `${window.location.origin}/${currentProvider.handle}`;
|
|
1196
|
+
const name = currentProvider.displayName || "your doctor";
|
|
1197
|
+
copyText(`Have health questions? Chat with ${name} anytime — free, private, and instant.\n\n${link}`);
|
|
1198
|
+
});
|
|
1199
|
+
|
|
1200
|
+
document.getElementById("copyEmailTemplate")?.addEventListener("click", () => {
|
|
1201
|
+
if (!currentProvider) return;
|
|
1202
|
+
const link = `${window.location.origin}/${currentProvider.handle}`;
|
|
1203
|
+
const name = currentProvider.displayName || "our practice";
|
|
1204
|
+
copyText(`Hi,\n\nWe're excited to share a new way to get answers to your health questions. Visit the link below to chat with ${name}'s AI health assistant — available 24/7 for general health info, appointment prep, and more.\n\n${link}\n\nThis service provides educational health information only. For emergencies, always call 911.`);
|
|
1205
|
+
});
|
|
1206
|
+
|
|
1207
|
+
document.getElementById("copySmsTemplate")?.addEventListener("click", () => {
|
|
1208
|
+
if (!currentProvider) return;
|
|
1209
|
+
const link = `${window.location.origin}/${currentProvider.handle}`;
|
|
1210
|
+
copyText(`Health questions? Chat with your doctor's AI assistant anytime: ${link}`);
|
|
1211
|
+
});
|
|
1212
|
+
|
|
1213
|
+
// Payment & Revenue Management
|
|
1214
|
+
const statRevenue = document.getElementById("statRevenue");
|
|
1215
|
+
const statTransactions = document.getElementById("statTransactions");
|
|
1216
|
+
const statSubscriptions = document.getElementById("statSubscriptions");
|
|
1217
|
+
const viewTransactionsBtn = document.getElementById("viewTransactionsBtn");
|
|
1218
|
+
const closeTransactionsBtn = document.getElementById("closeTransactionsBtn");
|
|
1219
|
+
const transactionsPanel = document.getElementById("transactionsPanel");
|
|
1220
|
+
const transactionsList = document.getElementById("transactionsList");
|
|
1221
|
+
const paymentEnabledSelect = document.getElementById("paymentEnabledSelect");
|
|
1222
|
+
const pricingModelSelect = document.getElementById("pricingModelSelect");
|
|
1223
|
+
const perChatPriceInput = document.getElementById("perChatPriceInput");
|
|
1224
|
+
const subscriptionPriceInput = document.getElementById("subscriptionPriceInput");
|
|
1225
|
+
const savePricingBtn = document.getElementById("savePricingBtn");
|
|
1226
|
+
const pricingError = document.getElementById("pricingError");
|
|
1227
|
+
|
|
1228
|
+
async function loadRevenueAnalytics() {
|
|
1229
|
+
try {
|
|
1230
|
+
const res = await fetchWithAuth("/api/payment/revenue");
|
|
1231
|
+
if (!res.ok) {
|
|
1232
|
+
console.error("Failed to load revenue analytics");
|
|
1233
|
+
return;
|
|
1234
|
+
}
|
|
1235
|
+
const data = await res.json();
|
|
1236
|
+
|
|
1237
|
+
statRevenue.textContent = `$${data.totalRevenue.toFixed(2)}`;
|
|
1238
|
+
statTransactions.textContent = data.transactionCount;
|
|
1239
|
+
statSubscriptions.textContent = data.activeSubscriptions;
|
|
1240
|
+
} catch (error) {
|
|
1241
|
+
console.error("Error loading revenue analytics:", error);
|
|
1242
|
+
}
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
async function loadPricingConfig() {
|
|
1246
|
+
if (!currentProvider) return;
|
|
1247
|
+
|
|
1248
|
+
paymentEnabledSelect.value = currentProvider.paymentEnabled ? "true" : "false";
|
|
1249
|
+
pricingModelSelect.value = currentProvider.pricingModel || "free";
|
|
1250
|
+
perChatPriceInput.value = currentProvider.perChatPrice || "";
|
|
1251
|
+
subscriptionPriceInput.value = currentProvider.subscriptionPrice || "";
|
|
1252
|
+
|
|
1253
|
+
// Update field visibility based on pricing model
|
|
1254
|
+
updatePricingFieldsVisibility();
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
function updatePricingFieldsVisibility() {
|
|
1258
|
+
const model = pricingModelSelect.value;
|
|
1259
|
+
const paymentEnabled = paymentEnabledSelect.value === "true";
|
|
1260
|
+
|
|
1261
|
+
perChatPriceInput.parentElement.style.display =
|
|
1262
|
+
paymentEnabled && (model === "per-chat" || model === "hybrid") ? "block" : "none";
|
|
1263
|
+
subscriptionPriceInput.parentElement.style.display =
|
|
1264
|
+
paymentEnabled && (model === "subscription" || model === "hybrid") ? "block" : "none";
|
|
1265
|
+
}
|
|
1266
|
+
|
|
1267
|
+
paymentEnabledSelect.addEventListener("change", updatePricingFieldsVisibility);
|
|
1268
|
+
pricingModelSelect.addEventListener("change", updatePricingFieldsVisibility);
|
|
1269
|
+
|
|
1270
|
+
savePricingBtn.addEventListener("click", async () => {
|
|
1271
|
+
setError(pricingError, "");
|
|
1272
|
+
|
|
1273
|
+
const paymentEnabled = paymentEnabledSelect.value === "true";
|
|
1274
|
+
const pricingModel = pricingModelSelect.value;
|
|
1275
|
+
const perChatPrice = parseFloat(perChatPriceInput.value) || 0;
|
|
1276
|
+
const subscriptionPrice = parseFloat(subscriptionPriceInput.value) || 0;
|
|
1277
|
+
|
|
1278
|
+
// Validation
|
|
1279
|
+
if (paymentEnabled && pricingModel !== "free") {
|
|
1280
|
+
if ((pricingModel === "per-chat" || pricingModel === "hybrid") && perChatPrice <= 0) {
|
|
1281
|
+
setError(pricingError, "Per-chat price must be greater than 0");
|
|
1282
|
+
return;
|
|
1283
|
+
}
|
|
1284
|
+
if ((pricingModel === "subscription" || pricingModel === "hybrid") && subscriptionPrice <= 0) {
|
|
1285
|
+
setError(pricingError, "Subscription price must be greater than 0");
|
|
1286
|
+
return;
|
|
1287
|
+
}
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1290
|
+
try {
|
|
1291
|
+
const res = await fetchWithAuth("/api/payment/update-pricing", {
|
|
1292
|
+
method: "POST",
|
|
1293
|
+
headers: { "Content-Type": "application/json" },
|
|
1294
|
+
body: JSON.stringify({
|
|
1295
|
+
paymentEnabled,
|
|
1296
|
+
pricingModel: paymentEnabled ? pricingModel : "free",
|
|
1297
|
+
perChatPrice,
|
|
1298
|
+
subscriptionPrice
|
|
1299
|
+
})
|
|
1300
|
+
});
|
|
1301
|
+
|
|
1302
|
+
if (!res.ok) {
|
|
1303
|
+
const error = await res.json();
|
|
1304
|
+
setError(pricingError, error.error || "Failed to update pricing");
|
|
1305
|
+
return;
|
|
1306
|
+
}
|
|
1307
|
+
|
|
1308
|
+
setError(pricingError, "✓ Pricing updated successfully");
|
|
1309
|
+
await loadProvider();
|
|
1310
|
+
await loadPricingConfig();
|
|
1311
|
+
} catch (error) {
|
|
1312
|
+
console.error("Error updating pricing:", error);
|
|
1313
|
+
setError(pricingError, "Failed to update pricing");
|
|
1314
|
+
}
|
|
1315
|
+
});
|
|
1316
|
+
|
|
1317
|
+
viewTransactionsBtn.addEventListener("click", async () => {
|
|
1318
|
+
try {
|
|
1319
|
+
const res = await fetchWithAuth("/api/payment/transactions?limit=50");
|
|
1320
|
+
if (!res.ok) {
|
|
1321
|
+
console.error("Failed to load transactions");
|
|
1322
|
+
return;
|
|
1323
|
+
}
|
|
1324
|
+
const data = await res.json();
|
|
1325
|
+
|
|
1326
|
+
if (data.transactions.length === 0) {
|
|
1327
|
+
transactionsList.innerHTML = "<p style='color: #666;'>No transactions yet</p>";
|
|
1328
|
+
} else {
|
|
1329
|
+
transactionsList.innerHTML = data.transactions.map(tx => `
|
|
1330
|
+
<div style="padding: 1rem; border: 1px solid #e0e0e0; border-radius: 8px; margin-bottom: 0.5rem;">
|
|
1331
|
+
<div style="display: flex; justify-content: space-between; align-items: center;">
|
|
1332
|
+
<div>
|
|
1333
|
+
<strong>$${(tx.amount / 100).toFixed(2)}</strong>
|
|
1334
|
+
<span style="color: #666; margin-left: 0.5rem;">${tx.status}</span>
|
|
1335
|
+
</div>
|
|
1336
|
+
<div style="color: #666; font-size: 0.875rem;">
|
|
1337
|
+
${new Date(tx.createdAt).toLocaleString()}
|
|
1338
|
+
</div>
|
|
1339
|
+
</div>
|
|
1340
|
+
<div style="color: #666; font-size: 0.875rem; margin-top: 0.5rem;">
|
|
1341
|
+
Patient: ${tx.patientId}
|
|
1342
|
+
${tx.conversationId ? `<br>Conversation: ${tx.conversationId}` : ""}
|
|
1343
|
+
</div>
|
|
1344
|
+
</div>
|
|
1345
|
+
`).join("");
|
|
1346
|
+
}
|
|
1347
|
+
|
|
1348
|
+
transactionsPanel.style.display = "block";
|
|
1349
|
+
} catch (error) {
|
|
1350
|
+
console.error("Error loading transactions:", error);
|
|
1351
|
+
}
|
|
1352
|
+
});
|
|
1353
|
+
|
|
1354
|
+
closeTransactionsBtn.addEventListener("click", () => {
|
|
1355
|
+
transactionsPanel.style.display = "none";
|
|
1356
|
+
});
|
|
1357
|
+
|
|
1358
|
+
// Billing Setup Event Handlers
|
|
1359
|
+
const createStripeAccountBtn = document.getElementById("createStripeAccountBtn");
|
|
1360
|
+
const completeStripeOnboardingBtn = document.getElementById("completeStripeOnboardingBtn");
|
|
1361
|
+
const saveBillingPricingBtn = document.getElementById("saveBillingPricingBtn");
|
|
1362
|
+
const skipBillingBtn = document.getElementById("skipBillingBtn");
|
|
1363
|
+
const billingPricingModel = document.getElementById("billingPricingModel");
|
|
1364
|
+
const billingSubscriptionPrice = document.getElementById("billingSubscriptionPrice");
|
|
1365
|
+
const billingPerChatPrice = document.getElementById("billingPerChatPrice");
|
|
1366
|
+
const subscriptionPricingFields = document.getElementById("subscriptionPricingFields");
|
|
1367
|
+
const perChatPricingFields = document.getElementById("perChatPricingFields");
|
|
1368
|
+
const billingPricingError = document.getElementById("billingPricingError");
|
|
1369
|
+
const stripeError = document.getElementById("stripeError");
|
|
1370
|
+
|
|
1371
|
+
// Toggle pricing fields based on model selection
|
|
1372
|
+
billingPricingModel.addEventListener("change", (e) => {
|
|
1373
|
+
const model = e.target.value;
|
|
1374
|
+
setVisible(subscriptionPricingFields, model === "subscription");
|
|
1375
|
+
setVisible(perChatPricingFields, model === "per-chat");
|
|
1376
|
+
});
|
|
1377
|
+
|
|
1378
|
+
// Create Stripe Connect Account
|
|
1379
|
+
createStripeAccountBtn.addEventListener("click", async () => {
|
|
1380
|
+
setError(stripeError, "Creating Stripe account...");
|
|
1381
|
+
createStripeAccountBtn.disabled = true;
|
|
1382
|
+
|
|
1383
|
+
try {
|
|
1384
|
+
const res = await fetchWithAuth("/api/stripe/create-connect-account", {
|
|
1385
|
+
method: "POST",
|
|
1386
|
+
headers: { "Content-Type": "application/json" }
|
|
1387
|
+
});
|
|
1388
|
+
|
|
1389
|
+
if (!res.ok) {
|
|
1390
|
+
const error = await res.json();
|
|
1391
|
+
setError(stripeError, error.error || "Failed to create Stripe account");
|
|
1392
|
+
createStripeAccountBtn.disabled = false;
|
|
1393
|
+
return;
|
|
1394
|
+
}
|
|
1395
|
+
|
|
1396
|
+
const data = await res.json();
|
|
1397
|
+
setError(stripeError, "✓ Stripe account created! Redirecting to onboarding...");
|
|
1398
|
+
|
|
1399
|
+
// Reload provider to get updated Stripe account ID
|
|
1400
|
+
const provider = await loadProvider();
|
|
1401
|
+
await renderBillingSetup(provider);
|
|
1402
|
+
} catch (error) {
|
|
1403
|
+
console.error("Error creating Stripe account:", error);
|
|
1404
|
+
setError(stripeError, "Failed to create Stripe account");
|
|
1405
|
+
createStripeAccountBtn.disabled = false;
|
|
1406
|
+
}
|
|
1407
|
+
});
|
|
1408
|
+
|
|
1409
|
+
// Complete Stripe Onboarding
|
|
1410
|
+
completeStripeOnboardingBtn.addEventListener("click", async () => {
|
|
1411
|
+
completeStripeOnboardingBtn.disabled = true;
|
|
1412
|
+
completeStripeOnboardingBtn.textContent = "Generating link...";
|
|
1413
|
+
|
|
1414
|
+
try {
|
|
1415
|
+
const res = await fetchWithAuth("/api/stripe/create-account-link", {
|
|
1416
|
+
method: "POST",
|
|
1417
|
+
headers: { "Content-Type": "application/json" },
|
|
1418
|
+
body: JSON.stringify({
|
|
1419
|
+
returnUrl: `${window.location.origin}/app`,
|
|
1420
|
+
refreshUrl: `${window.location.origin}/app`
|
|
1421
|
+
})
|
|
1422
|
+
});
|
|
1423
|
+
|
|
1424
|
+
if (!res.ok) {
|
|
1425
|
+
const error = await res.json();
|
|
1426
|
+
alert(error.error || "Failed to generate onboarding link");
|
|
1427
|
+
completeStripeOnboardingBtn.disabled = false;
|
|
1428
|
+
completeStripeOnboardingBtn.textContent = "Complete Stripe Setup";
|
|
1429
|
+
return;
|
|
1430
|
+
}
|
|
1431
|
+
|
|
1432
|
+
const data = await res.json();
|
|
1433
|
+
// Redirect to Stripe onboarding
|
|
1434
|
+
window.location.href = data.url;
|
|
1435
|
+
} catch (error) {
|
|
1436
|
+
console.error("Error generating Stripe onboarding link:", error);
|
|
1437
|
+
alert("Failed to generate onboarding link");
|
|
1438
|
+
completeStripeOnboardingBtn.disabled = false;
|
|
1439
|
+
completeStripeOnboardingBtn.textContent = "Complete Stripe Setup";
|
|
1440
|
+
}
|
|
1441
|
+
});
|
|
1442
|
+
|
|
1443
|
+
// Save Billing Pricing
|
|
1444
|
+
saveBillingPricingBtn.addEventListener("click", async () => {
|
|
1445
|
+
setError(billingPricingError, "");
|
|
1446
|
+
saveBillingPricingBtn.disabled = true;
|
|
1447
|
+
|
|
1448
|
+
const pricingModel = billingPricingModel.value;
|
|
1449
|
+
const subscriptionPrice = parseFloat(billingSubscriptionPrice.value) || 0;
|
|
1450
|
+
const perChatPrice = parseFloat(billingPerChatPrice.value) || 0;
|
|
1451
|
+
|
|
1452
|
+
// Validation
|
|
1453
|
+
if (pricingModel === "subscription" && subscriptionPrice <= 0) {
|
|
1454
|
+
setError(billingPricingError, "Please enter a valid subscription price");
|
|
1455
|
+
saveBillingPricingBtn.disabled = false;
|
|
1456
|
+
return;
|
|
1457
|
+
}
|
|
1458
|
+
|
|
1459
|
+
if (pricingModel === "per-chat" && perChatPrice <= 0) {
|
|
1460
|
+
setError(billingPricingError, "Please enter a valid per-chat price");
|
|
1461
|
+
saveBillingPricingBtn.disabled = false;
|
|
1462
|
+
return;
|
|
1463
|
+
}
|
|
1464
|
+
|
|
1465
|
+
try {
|
|
1466
|
+
const payload = {
|
|
1467
|
+
paymentEnabled: pricingModel !== "free",
|
|
1468
|
+
pricingModel: pricingModel,
|
|
1469
|
+
subscriptionPrice: subscriptionPrice,
|
|
1470
|
+
perChatPrice: perChatPrice
|
|
1471
|
+
};
|
|
1472
|
+
|
|
1473
|
+
const res = await fetchWithAuth("/api/payment/update-pricing", {
|
|
1474
|
+
method: "POST",
|
|
1475
|
+
headers: { "Content-Type": "application/json" },
|
|
1476
|
+
body: JSON.stringify(payload)
|
|
1477
|
+
});
|
|
1478
|
+
|
|
1479
|
+
if (!res.ok) {
|
|
1480
|
+
const error = await res.json();
|
|
1481
|
+
setError(billingPricingError, error.error || "Failed to save pricing");
|
|
1482
|
+
saveBillingPricingBtn.disabled = false;
|
|
1483
|
+
return;
|
|
1484
|
+
}
|
|
1485
|
+
|
|
1486
|
+
setError(billingPricingError, "✓ Pricing saved! Loading dashboard...");
|
|
1487
|
+
|
|
1488
|
+
// Reload provider and show dashboard
|
|
1489
|
+
const provider = await loadProvider();
|
|
1490
|
+
await renderDashboard(provider);
|
|
1491
|
+
} catch (error) {
|
|
1492
|
+
console.error("Error saving pricing:", error);
|
|
1493
|
+
setError(billingPricingError, "Failed to save pricing");
|
|
1494
|
+
saveBillingPricingBtn.disabled = false;
|
|
1495
|
+
}
|
|
1496
|
+
});
|
|
1497
|
+
|
|
1498
|
+
// Skip Billing Setup
|
|
1499
|
+
skipBillingBtn.addEventListener("click", async () => {
|
|
1500
|
+
try {
|
|
1501
|
+
// Set to free model and continue to dashboard
|
|
1502
|
+
const res = await fetchWithAuth("/api/payment/update-pricing", {
|
|
1503
|
+
method: "POST",
|
|
1504
|
+
headers: { "Content-Type": "application/json" },
|
|
1505
|
+
body: JSON.stringify({
|
|
1506
|
+
paymentEnabled: false,
|
|
1507
|
+
pricingModel: "free",
|
|
1508
|
+
subscriptionPrice: 0,
|
|
1509
|
+
perChatPrice: 0
|
|
1510
|
+
})
|
|
1511
|
+
});
|
|
1512
|
+
|
|
1513
|
+
if (!res.ok) {
|
|
1514
|
+
console.error("Failed to skip billing setup");
|
|
1515
|
+
return;
|
|
1516
|
+
}
|
|
1517
|
+
|
|
1518
|
+
// Reload provider and show dashboard
|
|
1519
|
+
const provider = await loadProvider();
|
|
1520
|
+
await renderDashboard(provider);
|
|
1521
|
+
} catch (error) {
|
|
1522
|
+
console.error("Error skipping billing:", error);
|
|
1523
|
+
}
|
|
1524
|
+
});
|
|
1525
|
+
|
|
1526
|
+
// Waitlist form
|
|
1527
|
+
document.getElementById('waitlistForm').addEventListener('submit', async (e) => {
|
|
1528
|
+
e.preventDefault();
|
|
1529
|
+
const msg = document.getElementById('waitlistMessage');
|
|
1530
|
+
const btn = document.getElementById('waitlistSubmitBtn');
|
|
1531
|
+
const email = document.getElementById('waitlistEmail').value.trim();
|
|
1532
|
+
const name = document.getElementById('waitlistName').value.trim();
|
|
1533
|
+
const specialty = document.getElementById('waitlistSpecialty').value.trim();
|
|
1534
|
+
|
|
1535
|
+
msg.textContent = 'Submitting...';
|
|
1536
|
+
btn.disabled = true;
|
|
1537
|
+
|
|
1538
|
+
try {
|
|
1539
|
+
const res = await fetch('/api/waitlist', {
|
|
1540
|
+
method: 'POST',
|
|
1541
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1542
|
+
body: JSON.stringify({ email, name, specialty })
|
|
1543
|
+
});
|
|
1544
|
+
const data = await res.json();
|
|
1545
|
+
if (res.ok) {
|
|
1546
|
+
msg.textContent = data.status === 'already_registered'
|
|
1547
|
+
? 'You are already on the waitlist. We will be in touch!'
|
|
1548
|
+
: 'You are on the list! We will notify you when EvalDoc launches on April 1, 2026.';
|
|
1549
|
+
document.getElementById('waitlistForm').reset();
|
|
1550
|
+
} else {
|
|
1551
|
+
msg.textContent = data.error || 'Something went wrong. Please try again.';
|
|
1552
|
+
btn.disabled = false;
|
|
1553
|
+
}
|
|
1554
|
+
} catch {
|
|
1555
|
+
msg.textContent = 'Network error. Please try again.';
|
|
1556
|
+
btn.disabled = false;
|
|
1557
|
+
}
|
|
1558
|
+
});
|
|
1559
|
+
|
|
1560
|
+
// "Already approved? Sign in" toggle
|
|
1561
|
+
document.getElementById('showAuthLink').addEventListener('click', (e) => {
|
|
1562
|
+
e.preventDefault();
|
|
1563
|
+
setVisible(waitlistPanel, false);
|
|
1564
|
+
setVisible(authPanel, true);
|
|
1565
|
+
});
|
|
1566
|
+
|
|
1567
|
+
// Notifications
|
|
1568
|
+
async function loadNotifications() {
|
|
1569
|
+
try {
|
|
1570
|
+
const res = await fetchWithAuth("/api/doctor/notifications?limit=10");
|
|
1571
|
+
if (!res.ok) return;
|
|
1572
|
+
const data = await res.json();
|
|
1573
|
+
const badge = document.getElementById("notificationBadge");
|
|
1574
|
+
if (badge) {
|
|
1575
|
+
if (data.unreadCount > 0) {
|
|
1576
|
+
badge.textContent = data.unreadCount > 9 ? "9+" : data.unreadCount;
|
|
1577
|
+
badge.style.display = "flex";
|
|
1578
|
+
} else {
|
|
1579
|
+
badge.style.display = "none";
|
|
1580
|
+
}
|
|
1581
|
+
}
|
|
1582
|
+
const list = document.getElementById("notificationList");
|
|
1583
|
+
if (list) {
|
|
1584
|
+
if (!data.notifications || !data.notifications.length) {
|
|
1585
|
+
list.innerHTML = '<div class="notification-empty">No notifications</div>';
|
|
1586
|
+
} else {
|
|
1587
|
+
list.innerHTML = data.notifications.map(n => {
|
|
1588
|
+
const time = n.createdAt ? new Date(n.createdAt._seconds ? n.createdAt._seconds * 1000 : n.createdAt).toLocaleString() : "";
|
|
1589
|
+
return `<div class="notification-item${n.read ? "" : " unread"}" data-id="${n.id}">
|
|
1590
|
+
<div class="notification-title">${escapeHtml(n.title || "")}</div>
|
|
1591
|
+
<div class="notification-body">${escapeHtml(n.body || "")}</div>
|
|
1592
|
+
<div class="notification-time">${time}</div>
|
|
1593
|
+
</div>`;
|
|
1594
|
+
}).join("");
|
|
1595
|
+
}
|
|
1596
|
+
}
|
|
1597
|
+
} catch (e) {
|
|
1598
|
+
// non-critical
|
|
1599
|
+
}
|
|
1600
|
+
}
|
|
1601
|
+
|
|
1602
|
+
const notificationBell = document.getElementById("notificationBell");
|
|
1603
|
+
const notificationDropdown = document.getElementById("notificationDropdown");
|
|
1604
|
+
if (notificationBell && notificationDropdown) {
|
|
1605
|
+
notificationBell.addEventListener("click", (e) => {
|
|
1606
|
+
e.stopPropagation();
|
|
1607
|
+
const isVisible = notificationDropdown.style.display !== "none";
|
|
1608
|
+
notificationDropdown.style.display = isVisible ? "none" : "block";
|
|
1609
|
+
if (!isVisible) loadNotifications();
|
|
1610
|
+
});
|
|
1611
|
+
document.addEventListener("click", (e) => {
|
|
1612
|
+
if (!notificationDropdown.contains(e.target) && e.target !== notificationBell) {
|
|
1613
|
+
notificationDropdown.style.display = "none";
|
|
1614
|
+
}
|
|
1615
|
+
});
|
|
1616
|
+
}
|
|
1617
|
+
|
|
1618
|
+
const markAllReadBtn = document.getElementById("markAllReadBtn");
|
|
1619
|
+
if (markAllReadBtn) {
|
|
1620
|
+
markAllReadBtn.addEventListener("click", async () => {
|
|
1621
|
+
await fetchWithAuth("/api/doctor/notifications/read", {
|
|
1622
|
+
method: "POST",
|
|
1623
|
+
headers: { "Content-Type": "application/json" },
|
|
1624
|
+
body: JSON.stringify({})
|
|
1625
|
+
});
|
|
1626
|
+
await loadNotifications();
|
|
1627
|
+
});
|
|
1628
|
+
}
|
|
1629
|
+
|
|
1630
|
+
// Conversation filters
|
|
1631
|
+
const conversationSearch = document.getElementById("conversationSearch");
|
|
1632
|
+
const conversationFilter = document.getElementById("conversationFilter");
|
|
1633
|
+
|
|
1634
|
+
if (conversationSearch) {
|
|
1635
|
+
let searchTimeout;
|
|
1636
|
+
conversationSearch.addEventListener("input", () => {
|
|
1637
|
+
clearTimeout(searchTimeout);
|
|
1638
|
+
searchTimeout = setTimeout(() => loadConversations(false), 300);
|
|
1639
|
+
});
|
|
1640
|
+
}
|
|
1641
|
+
|
|
1642
|
+
if (conversationFilter) {
|
|
1643
|
+
conversationFilter.addEventListener("change", () => loadConversations(false));
|
|
1644
|
+
}
|
|
1645
|
+
|
|
1646
|
+
document.addEventListener("keydown", (e) => {
|
|
1647
|
+
if (e.target.tagName === "INPUT" || e.target.tagName === "TEXTAREA" || e.target.tagName === "SELECT") return;
|
|
1648
|
+
if (e.key === "r" && !e.ctrlKey && !e.metaKey) {
|
|
1649
|
+
loadAnalytics();
|
|
1650
|
+
loadConversations();
|
|
1651
|
+
}
|
|
1652
|
+
if (e.key === "?" && e.shiftKey) {
|
|
1653
|
+
showToast("Shortcuts: R=refresh, E=export, P=preview chat");
|
|
1654
|
+
}
|
|
1655
|
+
if (e.key === "e" && !e.ctrlKey && !e.metaKey) {
|
|
1656
|
+
document.getElementById("exportConversationsBtn")?.click();
|
|
1657
|
+
}
|
|
1658
|
+
if (e.key === "p" && !e.ctrlKey && !e.metaKey) {
|
|
1659
|
+
previewChatBtn?.click();
|
|
1660
|
+
}
|
|
1661
|
+
});
|
|
1662
|
+
|
|
1663
|
+
// Account management — data export & account deletion (GDPR self-serve)
|
|
1664
|
+
const exportAccountBtn = document.getElementById("exportAccountBtn");
|
|
1665
|
+
if (exportAccountBtn) {
|
|
1666
|
+
exportAccountBtn.addEventListener("click", async () => {
|
|
1667
|
+
const msg = document.getElementById("accountMessage");
|
|
1668
|
+
if (msg) msg.textContent = "Preparing your data...";
|
|
1669
|
+
exportAccountBtn.disabled = true;
|
|
1670
|
+
try {
|
|
1671
|
+
const res = await fetchWithAuth("/api/account/export");
|
|
1672
|
+
if (!res.ok) {
|
|
1673
|
+
if (msg) msg.textContent = "Export failed. Please try again.";
|
|
1674
|
+
return;
|
|
1675
|
+
}
|
|
1676
|
+
const blob = await res.blob();
|
|
1677
|
+
const url = URL.createObjectURL(blob);
|
|
1678
|
+
const a = document.createElement("a");
|
|
1679
|
+
a.href = url;
|
|
1680
|
+
a.download = `evaldoc-export-${new Date().toISOString().slice(0, 10)}.json`;
|
|
1681
|
+
document.body.appendChild(a);
|
|
1682
|
+
a.click();
|
|
1683
|
+
document.body.removeChild(a);
|
|
1684
|
+
URL.revokeObjectURL(url);
|
|
1685
|
+
if (msg) msg.textContent = "Your data has been downloaded.";
|
|
1686
|
+
} catch (e) {
|
|
1687
|
+
if (msg) msg.textContent = "Export failed. Please try again.";
|
|
1688
|
+
} finally {
|
|
1689
|
+
exportAccountBtn.disabled = false;
|
|
1690
|
+
}
|
|
1691
|
+
});
|
|
1692
|
+
}
|
|
1693
|
+
|
|
1694
|
+
const deleteAccountBtn = document.getElementById("deleteAccountBtn");
|
|
1695
|
+
const deleteConfirm = document.getElementById("deleteConfirm");
|
|
1696
|
+
const deleteConfirmInput = document.getElementById("deleteConfirmInput");
|
|
1697
|
+
const confirmDeleteBtn = document.getElementById("confirmDeleteBtn");
|
|
1698
|
+
const cancelDeleteBtn = document.getElementById("cancelDeleteBtn");
|
|
1699
|
+
const deleteError = document.getElementById("deleteError");
|
|
1700
|
+
|
|
1701
|
+
if (deleteAccountBtn && deleteConfirm) {
|
|
1702
|
+
deleteAccountBtn.addEventListener("click", () => {
|
|
1703
|
+
setVisible(deleteConfirm, true);
|
|
1704
|
+
if (deleteConfirmInput) {
|
|
1705
|
+
deleteConfirmInput.value = "";
|
|
1706
|
+
deleteConfirmInput.focus();
|
|
1707
|
+
}
|
|
1708
|
+
if (confirmDeleteBtn) confirmDeleteBtn.disabled = true;
|
|
1709
|
+
setError(deleteError, "");
|
|
1710
|
+
});
|
|
1711
|
+
}
|
|
1712
|
+
|
|
1713
|
+
if (cancelDeleteBtn && deleteConfirm) {
|
|
1714
|
+
cancelDeleteBtn.addEventListener("click", () => {
|
|
1715
|
+
setVisible(deleteConfirm, false);
|
|
1716
|
+
if (deleteConfirmInput) deleteConfirmInput.value = "";
|
|
1717
|
+
setError(deleteError, "");
|
|
1718
|
+
});
|
|
1719
|
+
}
|
|
1720
|
+
|
|
1721
|
+
if (deleteConfirmInput && confirmDeleteBtn) {
|
|
1722
|
+
deleteConfirmInput.addEventListener("input", () => {
|
|
1723
|
+
confirmDeleteBtn.disabled = deleteConfirmInput.value.trim() !== "DELETE";
|
|
1724
|
+
});
|
|
1725
|
+
}
|
|
1726
|
+
|
|
1727
|
+
if (confirmDeleteBtn) {
|
|
1728
|
+
confirmDeleteBtn.addEventListener("click", async () => {
|
|
1729
|
+
if (deleteConfirmInput && deleteConfirmInput.value.trim() !== "DELETE") return;
|
|
1730
|
+
confirmDeleteBtn.disabled = true;
|
|
1731
|
+
setError(deleteError, "Deleting your account...");
|
|
1732
|
+
try {
|
|
1733
|
+
const res = await fetchWithAuth("/api/account", {
|
|
1734
|
+
method: "DELETE",
|
|
1735
|
+
headers: { "Content-Type": "application/json" },
|
|
1736
|
+
body: JSON.stringify({ confirm: "DELETE" })
|
|
1737
|
+
});
|
|
1738
|
+
if (!res.ok) {
|
|
1739
|
+
const data = await res.json().catch(() => ({}));
|
|
1740
|
+
setError(deleteError, data.error || "Failed to delete account.");
|
|
1741
|
+
confirmDeleteBtn.disabled = false;
|
|
1742
|
+
return;
|
|
1743
|
+
}
|
|
1744
|
+
// Sign out and reload to a clean state.
|
|
1745
|
+
await firebase.auth().signOut();
|
|
1746
|
+
window.location.reload();
|
|
1747
|
+
} catch (e) {
|
|
1748
|
+
setError(deleteError, "Failed to delete account.");
|
|
1749
|
+
confirmDeleteBtn.disabled = false;
|
|
1750
|
+
}
|
|
1751
|
+
});
|
|
1752
|
+
}
|
|
1753
|
+
|
|
1754
|
+
initAuth();
|