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.
Files changed (139) hide show
  1. package/.env.staging.example +19 -0
  2. package/.firebaserc +6 -0
  3. package/.github/workflows/auto-merge-to-main.yml +28 -0
  4. package/.github/workflows/ci.yml +27 -0
  5. package/.github/workflows/npxhub-publish.yml +170 -0
  6. package/.prettierignore +4 -0
  7. package/.prettierrc.json +7 -0
  8. package/README.md +142 -0
  9. package/SRS.md +448 -0
  10. package/apphosting.staging.yaml +48 -0
  11. package/apphosting.yaml +50 -0
  12. package/bin/evaldoc.cjs +105 -0
  13. package/cors.json +8 -0
  14. package/docs/ARCHITECTURE.md +769 -0
  15. package/docs/ARCHITECTURE_SUMMARY.md +323 -0
  16. package/docs/DEPLOYMENT.md +86 -0
  17. package/docs/DEPLOYMENT_STEPS.md +373 -0
  18. package/docs/DEPLOY_FIRESTORE_RULES.md +239 -0
  19. package/docs/DEPLOY_STORAGE_RULES.md +82 -0
  20. package/docs/DOCTOR_TUTORIAL.md +467 -0
  21. package/docs/FIREBASE_STORAGE_CORS_FIX.md +131 -0
  22. package/docs/FIXES_APPLIED.md +128 -0
  23. package/docs/IMPLEMENTATION.md +498 -0
  24. package/docs/IMPLEMENTATION_COMPLETE.md +317 -0
  25. package/docs/LLM-SETUP.md +216 -0
  26. package/docs/MANUAL_TEST_GUIDE.md +327 -0
  27. package/docs/PATIENT_GUIDE.md +451 -0
  28. package/docs/PLATFORM_OVERVIEW.md +194 -0
  29. package/docs/QUICKSTART.md +252 -0
  30. package/docs/QUICK_REFERENCE.md +222 -0
  31. package/docs/READY_TO_TEST.md +383 -0
  32. package/docs/RUN.md +237 -0
  33. package/docs/START_TESTING.md +130 -0
  34. package/docs/STRIPE_CONNECT_SETUP.md +454 -0
  35. package/docs/SYSTEM_READY.md +345 -0
  36. package/docs/THREADS_IMPLEMENTATION_COMPLETE.md +431 -0
  37. package/docs/THREAD_IMPLEMENTATION_GUIDE.md +345 -0
  38. package/docs/firebasefiles.md +21 -0
  39. package/docs/homepage.md +1208 -0
  40. package/docs/payments.md +159 -0
  41. package/eslint.config.js +76 -0
  42. package/firebase.json +27 -0
  43. package/firestore.indexes.json +88 -0
  44. package/firestore.rules +125 -0
  45. package/functions/package-lock.json +2884 -0
  46. package/functions/package.json +20 -0
  47. package/functions/src/analytics.js +101 -0
  48. package/functions/src/audit.js +28 -0
  49. package/functions/src/auth.js +19 -0
  50. package/functions/src/drafts.js +128 -0
  51. package/functions/src/feedback.js +164 -0
  52. package/functions/src/firebase-init.js +29 -0
  53. package/functions/src/i18n.js +134 -0
  54. package/functions/src/limits.js +27 -0
  55. package/functions/src/llm.js +271 -0
  56. package/functions/src/ner.js +162 -0
  57. package/functions/src/notifications.js +273 -0
  58. package/functions/src/orgs.js +127 -0
  59. package/functions/src/patient-api.js +545 -0
  60. package/functions/src/payment.js +830 -0
  61. package/functions/src/safety.js +188 -0
  62. package/functions/src/stripe-config.js +33 -0
  63. package/functions/src/topics.js +94 -0
  64. package/functions/src/triage.js +191 -0
  65. package/functions/src/utils.js +28 -0
  66. package/functions/src/validate.js +177 -0
  67. package/functions/test-firebase.js +36 -0
  68. package/jest.config.js +18 -0
  69. package/package.json +52 -0
  70. package/public/404.html +93 -0
  71. package/public/app/analytics.html +336 -0
  72. package/public/app/analytics.js +177 -0
  73. package/public/app/app.css +1400 -0
  74. package/public/app/app.js +1754 -0
  75. package/public/app/config.js +14 -0
  76. package/public/app/inbox-threads.js +843 -0
  77. package/public/app/inbox.html +598 -0
  78. package/public/app/inbox.js +397 -0
  79. package/public/app/index.html +612 -0
  80. package/public/assets/logo.jpg +0 -0
  81. package/public/chat.html +137 -0
  82. package/public/css/chat.css +870 -0
  83. package/public/css/landing-enhance.css +298 -0
  84. package/public/css/styles.css +1198 -0
  85. package/public/favicon.svg +5 -0
  86. package/public/find-doctor.html +220 -0
  87. package/public/index.html +399 -0
  88. package/public/js/chat-threads.js +513 -0
  89. package/public/js/chat.js +832 -0
  90. package/public/js/search.js +41 -0
  91. package/public/privacy.html +101 -0
  92. package/public/robots.txt +6 -0
  93. package/public/site.webmanifest +12 -0
  94. package/public/terms.html +110 -0
  95. package/scripts/create-test-provider.js +55 -0
  96. package/scripts/deploy-staging.sh +51 -0
  97. package/scripts/fix-provider.js +39 -0
  98. package/scripts/quick-test.sh +37 -0
  99. package/scripts/test-chat.sh +54 -0
  100. package/scripts/test-multi-turn.js +137 -0
  101. package/scripts/test-ner-simple.js +28 -0
  102. package/scripts/test-ner.js +60 -0
  103. package/scripts/test-stripe-connect.js +304 -0
  104. package/scripts/test-webhook-secret.sh +24 -0
  105. package/server.js +3864 -0
  106. package/storage.rules +22 -0
  107. package/test-api.json +1 -0
  108. package/tests/emulator/README.md +38 -0
  109. package/tests/emulator/counters.emulator.test.js +81 -0
  110. package/tests/integration/api-account-selfserve.test.js +312 -0
  111. package/tests/integration/api-admin-digest.test.js +202 -0
  112. package/tests/integration/api-chat-satisfaction.test.js +259 -0
  113. package/tests/integration/api-doctor-drafts.test.js +236 -0
  114. package/tests/integration/api-doctor-inbox.test.js +401 -0
  115. package/tests/integration/api-org.test.js +328 -0
  116. package/tests/integration/api-payment.test.js +664 -0
  117. package/tests/integration/api-provider-settings.test.js +475 -0
  118. package/tests/integration/api-qr.test.js +121 -0
  119. package/tests/integration/api-security.test.js +241 -0
  120. package/tests/integration/api-stripe-search.test.js +303 -0
  121. package/tests/integration/api-webhook-deletion.test.js +231 -0
  122. package/tests/mocks/firebase-admin.js +220 -0
  123. package/tests/unit/analytics.test.js +151 -0
  124. package/tests/unit/audit-bugs.test.js +203 -0
  125. package/tests/unit/chat-ui-logic.test.js +283 -0
  126. package/tests/unit/drafts.test.js +104 -0
  127. package/tests/unit/feedback.test.js +148 -0
  128. package/tests/unit/i18n.test.js +229 -0
  129. package/tests/unit/llm-retry.test.js +122 -0
  130. package/tests/unit/notifications.test.js +155 -0
  131. package/tests/unit/orgs.test.js +328 -0
  132. package/tests/unit/payment.test.js +889 -0
  133. package/tests/unit/safety-edge-cases.test.js +509 -0
  134. package/tests/unit/safety-srs-compliance.test.js +252 -0
  135. package/tests/unit/safety.test.js +291 -0
  136. package/tests/unit/server-helpers.test.js +774 -0
  137. package/tests/unit/topics-limits.test.js +158 -0
  138. package/tests/unit/triage.test.js +152 -0
  139. 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">&times;</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();