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,832 @@
1
+ const providerName = document.getElementById("providerName");
2
+ const providerMeta = document.getElementById("providerMeta");
3
+ const welcomeMessage = document.getElementById("welcomeMessage");
4
+ const topicSelect = document.getElementById("topicSelect");
5
+ const messages = document.getElementById("messages");
6
+ const messageInput = document.getElementById("messageInput");
7
+ const sendBtn = document.getElementById("sendBtn");
8
+ const brandBlock = document.getElementById("brandBlock");
9
+ const brandLogo = document.getElementById("brandLogo");
10
+ const ratingPanel = document.getElementById("ratingPanel");
11
+ const topicDisclaimer = document.getElementById("topicDisclaimer");
12
+
13
+ const TOPIC_DISCLAIMERS = {
14
+ medication_info_safety: "Information only. Do not use this as medical advice. Contact a licensed clinician for guidance.",
15
+ appointments_preparation: "General preparation info only. For urgent concerns, seek professional care.",
16
+ general_health_info: "General health information only. This is not medical advice.",
17
+ clinic_services_hours: "Clinic info only. For emergencies, call local emergency services.",
18
+ insurance_billing_faq: "General billing info only. Contact the clinic for account-specific questions.",
19
+ post_procedure_guidance: "General guidance only. If you have concerning symptoms, seek care right away."
20
+ };
21
+
22
+ // Friendly starter questions shown as tappable chips under an empty chat.
23
+ const TOPIC_STARTERS = {
24
+ medication_info_safety: "What should I know about this medication?",
25
+ appointments_preparation: "How should I prepare for my visit?",
26
+ general_health_info: "What are common causes of a headache?",
27
+ clinic_services_hours: "What are your opening hours?",
28
+ insurance_billing_faq: "Which insurance plans do you accept?",
29
+ post_procedure_guidance: "What's normal during recovery?"
30
+ };
31
+ let enabledTopics = [];
32
+
33
+ const suggestionsEl = document.getElementById("suggestions");
34
+ function hideSuggestions() {
35
+ if (suggestionsEl) suggestionsEl.innerHTML = "";
36
+ }
37
+ function showEmptyState() {
38
+ // Only when there's no prior conversation in the log.
39
+ if (!messages || messages.childElementCount > 0) return;
40
+ const greeting = (welcomeMessage && welcomeMessage.textContent.trim())
41
+ || "Hi! Ask me a general health question and I'll do my best to help — safely.";
42
+ addMessage("assistant", greeting);
43
+
44
+ if (!suggestionsEl) return;
45
+ const starters = (enabledTopics.length ? enabledTopics : Object.keys(TOPIC_STARTERS))
46
+ .filter((t) => TOPIC_STARTERS[t])
47
+ .slice(0, 4);
48
+ suggestionsEl.innerHTML = "";
49
+ starters.forEach((topic) => {
50
+ const chip = document.createElement("button");
51
+ chip.type = "button";
52
+ chip.className = "suggestion-chip";
53
+ chip.textContent = TOPIC_STARTERS[topic];
54
+ chip.addEventListener("click", () => {
55
+ if (topicSelect) { topicSelect.value = topic; topicSelect.dispatchEvent(new Event("change")); }
56
+ messageInput.value = TOPIC_STARTERS[topic];
57
+ hideSuggestions();
58
+ messageInput.focus();
59
+ if (typeof syncSendButton === "function") syncSendButton();
60
+ });
61
+ suggestionsEl.appendChild(chip);
62
+ });
63
+ }
64
+
65
+ // Payment elements
66
+ const paymentModal = document.getElementById("paymentModal");
67
+ const paymentTitle = document.getElementById("paymentTitle");
68
+ const paymentDescription = document.getElementById("paymentDescription");
69
+ const paymentForm = document.getElementById("paymentForm");
70
+ const paymentProcessing = document.getElementById("paymentProcessing");
71
+ const paymentSuccess = document.getElementById("paymentSuccess");
72
+ const paymentSubmitBtn = document.getElementById("paymentSubmitBtn");
73
+ const paymentCancelBtn = document.getElementById("paymentCancelBtn");
74
+ const cardErrors = document.getElementById("card-errors");
75
+
76
+ let stripe = null;
77
+ let cardElement = null;
78
+ let currentPaymentConfig = null;
79
+
80
+ const EMERGENCY_NUMBERS = {
81
+ US: '911', CA: '911', MX: '911',
82
+ GB: '999', IE: '999',
83
+ AU: '000', NZ: '111',
84
+ IN: '112', PK: '115', BD: '999',
85
+ NG: '112', GH: '112', KE: '999', ZA: '10177', EG: '123',
86
+ DE: '112', FR: '112', ES: '112', IT: '112', NL: '112', BE: '112',
87
+ PT: '112', PL: '112', SE: '112', NO: '113', DK: '112', FI: '112',
88
+ AT: '112', CH: '112', GR: '112', CZ: '112', RO: '112',
89
+ BR: '192', AR: '107', CO: '123', CL: '131', PE: '117',
90
+ JP: '119', KR: '119', CN: '120', TW: '119', PH: '911',
91
+ TH: '1669', MY: '999', SG: '995', ID: '118',
92
+ SA: '997', AE: '998', IL: '101', TR: '112',
93
+ RU: '103', UA: '103'
94
+ };
95
+
96
+ function updateEmergencyBanner(countryCode) {
97
+ const number = EMERGENCY_NUMBERS[countryCode] || '911';
98
+ const link = document.getElementById('emergencyLink');
99
+ const banner = document.getElementById('emergencyBanner');
100
+ if (link) {
101
+ link.href = `tel:${number}`;
102
+ link.textContent = number;
103
+ }
104
+ if (banner) {
105
+ banner.innerHTML = '';
106
+ banner.appendChild(document.createTextNode('\u{1F6A8} '));
107
+ const strong = document.createElement('strong');
108
+ strong.textContent = 'Emergency?';
109
+ banner.appendChild(strong);
110
+ banner.appendChild(document.createTextNode(' Call '));
111
+ const a = document.createElement('a');
112
+ a.href = `tel:${number}`;
113
+ a.textContent = number;
114
+ banner.appendChild(a);
115
+ banner.appendChild(document.createTextNode(' or your local emergency number. This is not a substitute for emergency care.'));
116
+ }
117
+ }
118
+
119
+ function getHandle() {
120
+ const path = window.location.pathname.replace(/^\//, "");
121
+ return path || "";
122
+ }
123
+
124
+ const SESSION_EXPIRY_MS = 24 * 60 * 60 * 1000; // 24 hours
125
+
126
+ function getSessionId() {
127
+ const key = "evaldoc_session";
128
+ const tsKey = "evaldoc_session_ts";
129
+ let sessionId = localStorage.getItem(key);
130
+ const sessionTs = parseInt(localStorage.getItem(tsKey) || "0", 10);
131
+ const elapsed = Date.now() - sessionTs;
132
+
133
+ if (!sessionId || elapsed > SESSION_EXPIRY_MS) {
134
+ const wasExpired = !!sessionId;
135
+ const bytes = new Uint8Array(16);
136
+ crypto.getRandomValues(bytes);
137
+ const hex = Array.from(bytes, b => b.toString(16).padStart(2, "0")).join("");
138
+ sessionId = `sess_${hex}`;
139
+ localStorage.setItem(key, sessionId);
140
+ localStorage.setItem(tsKey, String(Date.now()));
141
+ localStorage.removeItem("evaldoc_client_info");
142
+ if (wasExpired) {
143
+ setTimeout(() => addMessage("assistant", "Your previous session has expired. A new session has started."), 100);
144
+ }
145
+ } else if (elapsed > SESSION_EXPIRY_MS * 0.9 && !sessionStorage.getItem("evaldoc_expiry_warned")) {
146
+ sessionStorage.setItem("evaldoc_expiry_warned", "1");
147
+ setTimeout(() => addMessage("assistant", "Your session will expire soon. Any new messages will start a fresh conversation."), 100);
148
+ }
149
+ return sessionId;
150
+ }
151
+
152
+ function getClientInfo() {
153
+ const key = "evaldoc_client_info";
154
+ const stored = localStorage.getItem(key);
155
+ return stored ? JSON.parse(stored) : null;
156
+ }
157
+
158
+ function setClientInfo(name, contact) {
159
+ const key = "evaldoc_client_info";
160
+ const info = { name, contact };
161
+ localStorage.setItem(key, JSON.stringify(info));
162
+ }
163
+
164
+ // Extract contact information from user message
165
+ function extractContactInfo(message) {
166
+ const info = {};
167
+
168
+ // Extract email
169
+ const emailMatch = message.match(/\b[\w.+-]+@[\w-]+\.[\w.-]+\b/);
170
+ if (emailMatch) {
171
+ info.email = emailMatch[0];
172
+ }
173
+
174
+ // Extract phone (various formats)
175
+ const phoneMatch = message.match(/\b(?:\+?1[-.\s]?)?\(?([0-9]{3})\)?[-.\s]?([0-9]{3})[-.\s]?([0-9]{4})\b/);
176
+ if (phoneMatch) {
177
+ info.phone = phoneMatch[0];
178
+ }
179
+
180
+ return info;
181
+ }
182
+
183
+ function formatMessageText(text) {
184
+ // Escape quotes too — the URL below is dropped into an href attribute, so an
185
+ // unescaped " could break out of the attribute (XSS).
186
+ const escaped = String(text)
187
+ .replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")
188
+ .replace(/"/g, "&quot;").replace(/'/g, "&#39;");
189
+ return escaped
190
+ .replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>")
191
+ .replace(/^[-•] (.+)$/gm, "<li>$1</li>")
192
+ .replace(/((?:<li>.*<\/li>\n?)+)/g, "<ul>$1</ul>")
193
+ .replace(/(https?:\/\/[^\s<]+)/g, '<a href="$1" target="_blank" rel="noopener noreferrer">$1</a>')
194
+ .replace(/\n/g, "<br>");
195
+ }
196
+
197
+ function announceToScreenReader(text) {
198
+ const announcer = document.getElementById("srAnnouncer");
199
+ if (announcer) {
200
+ announcer.textContent = "";
201
+ // Brief delay so screen readers register the change
202
+ setTimeout(() => { announcer.textContent = text; }, 100);
203
+ }
204
+ }
205
+
206
+ function addMessage(role, text) {
207
+ const bubble = document.createElement("article");
208
+ bubble.className = `message ${role}`;
209
+ const roleLabel = role === "user" ? "You" : role === "doctor" ? "Doctor" : "Assistant";
210
+ bubble.setAttribute("aria-label", `${roleLabel} message`);
211
+ const content = document.createElement("span");
212
+ if (role === "user") {
213
+ content.textContent = text;
214
+ } else {
215
+ content.innerHTML = formatMessageText(text);
216
+ }
217
+ bubble.appendChild(content);
218
+ const ts = document.createElement("span");
219
+ ts.className = "message-time";
220
+ ts.textContent = new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
221
+ bubble.appendChild(ts);
222
+ messages.appendChild(bubble);
223
+ messages.scrollTop = messages.scrollHeight;
224
+ // New messages are announced by the messages container's own
225
+ // role="log" aria-live="polite" — no separate announcer (avoids double-speak).
226
+ }
227
+
228
+ let typingTimeout = null;
229
+
230
+ function showTypingIndicator() {
231
+ const indicator = document.createElement("div");
232
+ indicator.className = "message assistant typing-indicator";
233
+ indicator.id = "typingIndicator";
234
+ indicator.setAttribute("role", "status");
235
+ indicator.setAttribute("aria-label", "Assistant is typing");
236
+ for (let i = 0; i < 3; i++) {
237
+ const dot = document.createElement("span");
238
+ dot.className = "dot";
239
+ indicator.appendChild(dot);
240
+ }
241
+ messages.appendChild(indicator);
242
+ messages.scrollTop = messages.scrollHeight;
243
+ typingTimeout = setTimeout(() => {
244
+ const el = document.getElementById("typingIndicator");
245
+ if (el) el.setAttribute("aria-label", "Still waiting for response...");
246
+ }, 8000);
247
+ }
248
+
249
+ function hideTypingIndicator() {
250
+ if (typingTimeout) { clearTimeout(typingTimeout); typingTimeout = null; }
251
+ const indicator = document.getElementById("typingIndicator");
252
+ if (indicator) indicator.remove();
253
+ }
254
+
255
+ let lastConversationId = null;
256
+
257
+ function setBranding(provider) {
258
+ // Reflect the provider in the tab/title for shared links.
259
+ if (provider.displayName) {
260
+ document.title = `Chat with ${provider.displayName} · EvalDoc`;
261
+ }
262
+ // Preview-mode notice: provider link is live but credentials are not yet verified.
263
+ if (provider.isPreview) {
264
+ let pv = document.getElementById("previewNotice");
265
+ if (!pv) {
266
+ pv = document.createElement("div");
267
+ pv.id = "previewNotice";
268
+ pv.setAttribute("role", "status");
269
+ pv.style.cssText = "background:#fff7ed;color:#9a3412;border:1px solid #fdba74;border-radius:8px;padding:0.5rem 0.75rem;margin:0.5rem 0;font-size:0.8rem;text-align:center;";
270
+ pv.textContent = "Preview — this provider's credentials are still being verified.";
271
+ const container = document.querySelector(".chat-header") || document.body;
272
+ container.insertBefore(pv, container.firstChild);
273
+ }
274
+ }
275
+ if (provider.displayName) providerName.textContent = provider.displayName;
276
+ if (provider.specialty || provider.country) {
277
+ providerMeta.textContent = [provider.specialty, provider.country].filter(Boolean).join(" · ");
278
+ }
279
+ if (provider.country) {
280
+ updateEmergencyBanner(provider.country);
281
+ }
282
+ if (provider.welcomeMessage) welcomeMessage.textContent = provider.welcomeMessage;
283
+
284
+ if (provider.brandPrimaryColor && provider.brandSecondaryColor) {
285
+ brandBlock.style.background = `linear-gradient(135deg, ${provider.brandPrimaryColor}, ${provider.brandSecondaryColor})`;
286
+ }
287
+ if (provider.logoUrl || provider.photoUrl) {
288
+ const img = document.createElement("img");
289
+ img.src = provider.logoUrl || provider.photoUrl;
290
+ img.alt = provider.displayName || "Provider";
291
+ img.style.cssText = "width:100%;height:100%;object-fit:cover;border-radius:50%;";
292
+ brandLogo.textContent = "";
293
+ brandLogo.appendChild(img);
294
+ } else {
295
+ const initials = (provider.displayName || "ED")
296
+ .split(" ")
297
+ .map((part) => part[0])
298
+ .join("")
299
+ .slice(0, 2)
300
+ .toUpperCase();
301
+ brandLogo.textContent = initials || "ED";
302
+ }
303
+
304
+ const detailsEl = document.getElementById("providerDetails");
305
+ const statusEl = document.getElementById("providerStatus");
306
+ const hoursEl = document.getElementById("providerHours");
307
+ if (detailsEl && statusEl) {
308
+ const isPublished = provider.status === "published";
309
+ statusEl.textContent = isPublished ? "● Available" : "● Offline";
310
+ statusEl.className = "provider-status " + (isPublished ? "status-online" : "status-offline");
311
+ if (hoursEl && provider.businessHours) {
312
+ const bh = provider.businessHours;
313
+ hoursEl.textContent = bh.summary || (bh.open && bh.close ? `Hours: ${bh.open}–${bh.close}` : "");
314
+ }
315
+ detailsEl.style.display = "flex";
316
+ }
317
+
318
+ // Set country-specific emergency number
319
+ const country = (provider.country || "").toUpperCase().trim();
320
+ const emergencyEl = document.getElementById("emergencyNumber");
321
+ const bannerEl = document.getElementById("emergencyBanner");
322
+ if (emergencyEl && bannerEl) {
323
+ const emNumber = EMERGENCY_NUMBERS[country];
324
+ if (emNumber) {
325
+ emergencyEl.href = `tel:${emNumber}`;
326
+ emergencyEl.textContent = emNumber;
327
+ }
328
+ }
329
+
330
+ // Display provider contact info for patients (safe DOM construction)
331
+ const contactEl = document.getElementById("providerContactInfo");
332
+ if (contactEl && provider.contactInfo) {
333
+ const ci = provider.contactInfo;
334
+ contactEl.innerHTML = "";
335
+ if (ci.phone) {
336
+ const a = document.createElement("a");
337
+ a.href = "tel:" + ci.phone.replace(/[^0-9+\-() ]/g, "");
338
+ a.textContent = "\u{1F4DE} " + ci.phone;
339
+ contactEl.appendChild(a);
340
+ }
341
+ if (ci.email) {
342
+ const a = document.createElement("a");
343
+ a.href = "mailto:" + ci.email.replace(/[^a-zA-Z0-9@._+\-]/g, "");
344
+ a.textContent = "✉ " + ci.email;
345
+ contactEl.appendChild(a);
346
+ }
347
+ if (ci.address) {
348
+ const span = document.createElement("span");
349
+ span.textContent = "\u{1F4CD} " + ci.address;
350
+ contactEl.appendChild(span);
351
+ }
352
+ if (contactEl.childElementCount) {
353
+ contactEl.style.display = "flex";
354
+ }
355
+ }
356
+ }
357
+
358
+ async function loadProvider() {
359
+ const handle = getHandle();
360
+ if (!handle) {
361
+ providerName.textContent = "Invalid link";
362
+ providerMeta.textContent = "Missing provider handle";
363
+ return;
364
+ }
365
+
366
+ const res = await fetch(`/api/provider?handle=${encodeURIComponent(handle)}`);
367
+ if (!res.ok) {
368
+ providerName.textContent = "Provider unavailable";
369
+ providerMeta.textContent = "Please check the link";
370
+ return;
371
+ }
372
+
373
+ const provider = await res.json();
374
+ setBranding(provider);
375
+ const topics = provider.enabledTopics || [];
376
+ enabledTopics = topics;
377
+ topicSelect.innerHTML = "";
378
+ // Add auto-detect option first
379
+ const autoOption = document.createElement("option");
380
+ autoOption.value = "";
381
+ autoOption.textContent = "Auto-detect topic";
382
+ topicSelect.appendChild(autoOption);
383
+
384
+ topics.forEach((topic) => {
385
+ const option = document.createElement("option");
386
+ option.value = topic;
387
+ option.textContent = topic.replace(/_/g, " ");
388
+ topicSelect.appendChild(option);
389
+ });
390
+ if (!topics.length) {
391
+ autoOption.textContent = "General";
392
+ }
393
+
394
+ // Update disclaimer when topic changes
395
+ function updateTopicDisclaimer() {
396
+ if (topicDisclaimer) {
397
+ const selectedTopic = topicSelect.value;
398
+ topicDisclaimer.textContent = TOPIC_DISCLAIMERS[selectedTopic] || "This is not medical advice. For urgent concerns, contact local emergency services.";
399
+ }
400
+ }
401
+ topicSelect.addEventListener("change", updateTopicDisclaimer);
402
+ updateTopicDisclaimer();
403
+
404
+ // Fetch and display pricing info upfront
405
+ try {
406
+ const pricingRes = await fetch(`/api/payment/config/${encodeURIComponent(handle)}`);
407
+ if (pricingRes.ok) {
408
+ const pricing = await pricingRes.json();
409
+ if (pricing.paymentEnabled && pricing.pricingModel !== 'free') {
410
+ showPricingBanner(pricing);
411
+ }
412
+ }
413
+ } catch (err) {
414
+ // Pricing display is non-critical
415
+ }
416
+ }
417
+
418
+ function showPricingBanner(pricing) {
419
+ const existing = document.querySelector(".pricing-banner");
420
+ if (existing) existing.remove();
421
+
422
+ const banner = document.createElement("div");
423
+ banner.className = "pricing-banner";
424
+ let priceText = "";
425
+ if (pricing.pricingModel === "subscription") {
426
+ priceText = `Subscription: $${pricing.subscriptionPrice}/month`;
427
+ } else if (pricing.pricingModel === "per-chat") {
428
+ priceText = `$${pricing.perChatPrice} per conversation`;
429
+ } else if (pricing.pricingModel === "hybrid") {
430
+ priceText = `$${pricing.subscriptionPrice}/month or $${pricing.perChatPrice} per chat`;
431
+ }
432
+ const icon = document.createElement("span");
433
+ icon.className = "pricing-icon";
434
+ icon.textContent = "ℹ";
435
+ const text = document.createElement("span");
436
+ text.textContent = priceText;
437
+ banner.appendChild(icon);
438
+ banner.appendChild(document.createTextNode(" "));
439
+ banner.appendChild(text);
440
+ const welcomeCard = document.getElementById("welcomeCard");
441
+ if (welcomeCard) {
442
+ welcomeCard.parentNode.insertBefore(banner, welcomeCard.nextSibling);
443
+ }
444
+ }
445
+
446
+ const MAX_MESSAGE_LENGTH = 5000;
447
+
448
+ async function sendMessage() {
449
+ const text = messageInput.value.trim();
450
+ if (!text) return;
451
+
452
+ if (text.length > MAX_MESSAGE_LENGTH) {
453
+ addMessage("assistant", `Message too long (${text.length}/${MAX_MESSAGE_LENGTH} characters). Please shorten your message.`);
454
+ return;
455
+ }
456
+
457
+ const handle = getHandle();
458
+ const sessionId = getSessionId();
459
+ const topic = topicSelect.value;
460
+ const clientInfo = getClientInfo();
461
+
462
+ // Try to extract contact info from this message
463
+ const extracted = extractContactInfo(text);
464
+ if (extracted.email || extracted.phone) {
465
+ const existingInfo = getClientInfo() || {};
466
+ if (extracted.email && !existingInfo.contact) {
467
+ setClientInfo(existingInfo.name || '', extracted.email);
468
+ } else if (extracted.phone && !existingInfo.contact) {
469
+ setClientInfo(existingInfo.name || '', extracted.phone);
470
+ }
471
+ }
472
+
473
+ addMessage("user", text);
474
+ messageInput.value = "";
475
+ messageInput.style.height = "auto";
476
+ hideSuggestions();
477
+ sendBtn.disabled = true;
478
+ ratingPanel.classList.add("hidden");
479
+ lastConversationId = null;
480
+ showTypingIndicator();
481
+
482
+ try {
483
+ const payload = { handle, sessionId, message: text };
484
+ if (topic) payload.topic = topic;
485
+
486
+ // Include client contact information if available
487
+ const updatedClientInfo = getClientInfo();
488
+ if (updatedClientInfo) {
489
+ if (updatedClientInfo.name) payload.clientName = updatedClientInfo.name;
490
+ if (updatedClientInfo.contact) {
491
+ // Detect if contact is email or phone
492
+ if (updatedClientInfo.contact.includes('@')) {
493
+ payload.clientEmail = updatedClientInfo.contact;
494
+ } else {
495
+ payload.clientPhone = updatedClientInfo.contact;
496
+ }
497
+ }
498
+ }
499
+
500
+ const res = await fetch("/api/chat", {
501
+ method: "POST",
502
+ headers: { "Content-Type": "application/json" },
503
+ body: JSON.stringify(payload)
504
+ });
505
+
506
+ const data = await res.json();
507
+
508
+ hideTypingIndicator();
509
+
510
+ // Handle payment required
511
+ if (res.status === 402 && data.paymentRequired) {
512
+ await handlePaymentRequired(data.paymentConfig);
513
+ return;
514
+ }
515
+
516
+ if (res.status === 429) {
517
+ addMessage("assistant", data.error || "Chat limit reached for this provider. Please try again next month.");
518
+ return;
519
+ }
520
+
521
+ if (!res.ok) {
522
+ addMessage("assistant", data.error || "Unable to respond right now.");
523
+ return;
524
+ }
525
+
526
+ addMessage("assistant", data.response);
527
+ lastConversationId = data.conversationId || null;
528
+ if (lastConversationId) {
529
+ ratingPanel.classList.remove("hidden");
530
+ }
531
+ if (topicDisclaimer) {
532
+ topicDisclaimer.classList.add("disclaimer-highlight");
533
+ setTimeout(() => topicDisclaimer.classList.remove("disclaimer-highlight"), 1500);
534
+ }
535
+ // Update disclaimer and topic display to match server-resolved topic
536
+ if (data.topic && topicDisclaimer) {
537
+ topicDisclaimer.textContent = TOPIC_DISCLAIMERS[data.topic] || topicDisclaimer.textContent;
538
+ // If auto-detect was used, show resolved topic
539
+ if (!topicSelect.value && data.topic) {
540
+ const topicName = data.topic.replace(/_/g, " ");
541
+ const indicator = document.getElementById("autoDetectedTopic");
542
+ if (indicator) {
543
+ indicator.textContent = `Topic: ${topicName}`;
544
+ indicator.style.display = "inline-block";
545
+ }
546
+ }
547
+ }
548
+ if (data.escalation) {
549
+ addMessage("assistant", "Urgent concerns should be handled by a licensed clinician or emergency services.");
550
+ }
551
+ } catch (err) {
552
+ hideTypingIndicator();
553
+ const isOffline = !navigator.onLine;
554
+ const errorMsg = isOffline
555
+ ? "You appear to be offline. Check your connection and try again."
556
+ : "Something went wrong. This may be a temporary issue.";
557
+ const retryBubble = document.createElement("div");
558
+ retryBubble.className = "message assistant error-message";
559
+ retryBubble.setAttribute("role", "alert");
560
+ const msgSpan = document.createElement("span");
561
+ msgSpan.textContent = errorMsg + " ";
562
+ retryBubble.appendChild(msgSpan);
563
+ const retryBtn = document.createElement("button");
564
+ retryBtn.className = "retry-btn";
565
+ retryBtn.textContent = "Retry";
566
+ retryBtn.addEventListener("click", () => {
567
+ if (!navigator.onLine) {
568
+ msgSpan.textContent = "You appear to be offline. Check your connection and try again. ";
569
+ return;
570
+ }
571
+ retryBubble.remove();
572
+ messageInput.value = text;
573
+ sendMessage();
574
+ });
575
+ retryBubble.appendChild(retryBtn);
576
+ messages.appendChild(retryBubble);
577
+ messages.scrollTop = messages.scrollHeight;
578
+ } finally {
579
+ sendBtn.disabled = false;
580
+ messageInput.focus();
581
+ }
582
+ }
583
+
584
+ sendBtn.addEventListener("click", sendMessage);
585
+
586
+ // Enter sends; Shift+Enter inserts a newline (standard chat behavior).
587
+ messageInput.addEventListener("keydown", (event) => {
588
+ if (event.key === "Enter" && !event.shiftKey) {
589
+ event.preventDefault();
590
+ sendMessage();
591
+ }
592
+ });
593
+
594
+ messageInput.setAttribute("maxlength", MAX_MESSAGE_LENGTH);
595
+
596
+ // Auto-grow the textarea (up to the CSS max-height) as the patient types.
597
+ function autoGrowInput() {
598
+ messageInput.style.height = "auto";
599
+ messageInput.style.height = Math.min(messageInput.scrollHeight, 140) + "px";
600
+ }
601
+
602
+ // Keep the send button state in sync with the input.
603
+ function syncSendButton() {
604
+ const len = messageInput.value.length;
605
+ sendBtn.disabled = !messageInput.value.trim() || len > MAX_MESSAGE_LENGTH;
606
+ }
607
+ syncSendButton();
608
+
609
+ const charCounter = document.getElementById("charCounter");
610
+ messageInput.addEventListener("input", () => {
611
+ autoGrowInput();
612
+ syncSendButton();
613
+ if (charCounter) {
614
+ const len = messageInput.value.length;
615
+ if (len > MAX_MESSAGE_LENGTH * 0.8) {
616
+ charCounter.style.display = "block";
617
+ charCounter.textContent = `${len} / ${MAX_MESSAGE_LENGTH}`;
618
+ charCounter.style.color = len > MAX_MESSAGE_LENGTH * 0.95 ? "#dc3545" : "#6b7280";
619
+ } else {
620
+ charCounter.style.display = "none";
621
+ }
622
+ }
623
+ });
624
+
625
+ ratingPanel.addEventListener("click", async (event) => {
626
+ const button = event.target.closest("button[data-rating]");
627
+ if (!button || !lastConversationId) return;
628
+ const rating = Number(button.dataset.rating);
629
+ const buttons = ratingPanel.querySelectorAll("button[data-rating]");
630
+ buttons.forEach((b) => (b.disabled = true));
631
+ button.style.background = "#297B56";
632
+ button.style.color = "#fff";
633
+ try {
634
+ const resp = await fetch("/api/satisfaction", {
635
+ method: "POST",
636
+ headers: { "Content-Type": "application/json" },
637
+ body: JSON.stringify({ conversationId: lastConversationId, sessionId: getSessionId(), rating })
638
+ });
639
+ if (!resp.ok) throw new Error("Rating failed");
640
+ // Confirm politely, then tuck the panel away.
641
+ ratingPanel.setAttribute("aria-live", "polite");
642
+ ratingPanel.innerHTML = '<span class="rating-thanks">✓ Thanks for your feedback!</span>';
643
+ setTimeout(() => ratingPanel.classList.add("hidden"), 2500);
644
+ lastConversationId = null;
645
+ } catch (err) {
646
+ ratingPanel.innerHTML = '<span class="rating-scale-label">Could not save your rating. Please try again.</span>';
647
+ buttons.forEach((b) => (b.disabled = false));
648
+ }
649
+ });
650
+
651
+ async function loadChatHistory() {
652
+ const handle = getHandle();
653
+ const sessionId = getSessionId();
654
+ if (!handle || !sessionId) return;
655
+
656
+ try {
657
+ const res = await fetch(`/api/chat/history?handle=${encodeURIComponent(handle)}&sessionId=${encodeURIComponent(sessionId)}`);
658
+ if (!res.ok) return;
659
+ const data = await res.json();
660
+ if (data.messages && data.messages.length > 0) {
661
+ data.messages.forEach(msg => {
662
+ const role = msg.role === 'user' ? 'user' : msg.role === 'doctor' ? 'doctor' : 'assistant';
663
+ addMessage(role, msg.content);
664
+ });
665
+ lastConversationId = data.conversationId || null;
666
+ }
667
+ } catch (err) {
668
+ // Silently fail - history is a nice-to-have
669
+ }
670
+ }
671
+
672
+ // Payment handling
673
+ async function handlePaymentRequired(config) {
674
+ currentPaymentConfig = config;
675
+
676
+ if (config.pricingModel === 'subscription') {
677
+ // Redirect to Stripe Checkout for subscription
678
+ paymentTitle.textContent = "Subscription Required";
679
+ paymentDescription.textContent =
680
+ `This provider requires a subscription ($${config.subscriptionPrice}/month). You'll be redirected to complete payment.`;
681
+ paymentForm.innerHTML = "";
682
+ const subscribeBtn = document.createElement("button");
683
+ subscribeBtn.id = "subscribeBtn";
684
+ subscribeBtn.className = "payment-submit-btn";
685
+ subscribeBtn.textContent = "Subscribe Now";
686
+ paymentForm.appendChild(subscribeBtn);
687
+ const cancelBtn2 = document.createElement("button");
688
+ cancelBtn2.id = "paymentCancelBtn2";
689
+ cancelBtn2.className = "payment-cancel-btn";
690
+ cancelBtn2.textContent = "Cancel";
691
+ paymentForm.appendChild(cancelBtn2);
692
+ paymentModal.style.display = "flex";
693
+
694
+ subscribeBtn.addEventListener("click", async () => {
695
+ try {
696
+ subscribeBtn.disabled = true;
697
+ subscribeBtn.textContent = "Redirecting...";
698
+ const res = await fetch("/api/payment/create-checkout-session", {
699
+ method: "POST",
700
+ headers: { "Content-Type": "application/json" },
701
+ body: JSON.stringify({
702
+ handle: config.handle,
703
+ sessionId: getSessionId(),
704
+ interval: "month"
705
+ })
706
+ });
707
+ const data = await res.json();
708
+ if (data.url) {
709
+ window.location.href = data.url;
710
+ } else {
711
+ addMessage("assistant", "Unable to start payment. Please try again.");
712
+ paymentModal.style.display = "none";
713
+ }
714
+ } catch (err) {
715
+ addMessage("assistant", "Payment error. Please try again.");
716
+ paymentModal.style.display = "none";
717
+ }
718
+ });
719
+
720
+ cancelBtn2.addEventListener("click", () => {
721
+ paymentModal.style.display = "none";
722
+ });
723
+ } else if (config.pricingModel === 'per-chat') {
724
+ // Per-chat: create payment intent and show card form
725
+ paymentTitle.textContent = "Payment Required";
726
+ paymentDescription.textContent =
727
+ `This conversation costs $${config.perChatPrice}. Enter your card details below.`;
728
+
729
+ try {
730
+ const intentRes = await fetch("/api/payment/create-intent", {
731
+ method: "POST",
732
+ headers: { "Content-Type": "application/json" },
733
+ body: JSON.stringify({
734
+ handle: config.handle,
735
+ sessionId: getSessionId()
736
+ })
737
+ });
738
+ const intentData = await intentRes.json();
739
+
740
+ if (!intentData.clientSecret) {
741
+ addMessage("assistant", "Unable to set up payment. Please try again.");
742
+ return;
743
+ }
744
+
745
+ currentPaymentConfig.clientSecret = intentData.clientSecret;
746
+
747
+ // Initialize Stripe card element
748
+ if (!stripe && config.stripePublishableKey) {
749
+ stripe = Stripe(config.stripePublishableKey);
750
+ }
751
+ if (stripe && !cardElement) {
752
+ const elements = stripe.elements();
753
+ cardElement = elements.create("card");
754
+ cardElement.mount("#card-element");
755
+ cardElement.on("change", (event) => {
756
+ cardErrors.textContent = event.error ? event.error.message : "";
757
+ });
758
+ }
759
+
760
+ paymentModal.style.display = "flex";
761
+ } catch (err) {
762
+ addMessage("assistant", "Payment setup error. Please try again.");
763
+ }
764
+ }
765
+ }
766
+
767
+ paymentSubmitBtn.addEventListener("click", async () => {
768
+ if (!currentPaymentConfig || !currentPaymentConfig.clientSecret || !stripe || !cardElement) return;
769
+
770
+ paymentForm.style.display = "none";
771
+ paymentProcessing.style.display = "block";
772
+
773
+ try {
774
+ const result = await stripe.confirmCardPayment(currentPaymentConfig.clientSecret, {
775
+ payment_method: { card: cardElement }
776
+ });
777
+
778
+ if (result.error) {
779
+ throw new Error(result.error.message);
780
+ }
781
+
782
+ paymentProcessing.style.display = "none";
783
+ paymentSuccess.style.display = "block";
784
+
785
+ setTimeout(() => {
786
+ paymentModal.style.display = "none";
787
+ paymentSuccess.style.display = "none";
788
+ paymentForm.style.display = "block";
789
+ addMessage("assistant", "Payment successful! You can now continue chatting.");
790
+ }, 2000);
791
+ } catch (error) {
792
+ paymentProcessing.style.display = "none";
793
+ paymentForm.style.display = "block";
794
+ cardErrors.textContent = error.message;
795
+ }
796
+ });
797
+
798
+ paymentCancelBtn.addEventListener("click", () => {
799
+ paymentModal.style.display = "none";
800
+ });
801
+
802
+ // Close payment modal on Escape key
803
+ document.addEventListener("keydown", (event) => {
804
+ if (event.key === "Escape") {
805
+ if (paymentModal && paymentModal.style.display !== "none") {
806
+ paymentModal.style.display = "none";
807
+ messageInput.focus();
808
+ }
809
+ }
810
+ });
811
+
812
+ loadProvider().then(async () => {
813
+ await loadChatHistory();
814
+
815
+ // Handle payment return from Stripe Checkout
816
+ const params = new URLSearchParams(window.location.search);
817
+ if (params.get("payment") === "success") {
818
+ addMessage("assistant", "Payment successful! You can now chat with this provider.");
819
+ window.history.replaceState({}, "", window.location.pathname);
820
+ } else if (params.get("payment") === "cancelled") {
821
+ addMessage("assistant", "Payment was cancelled. You can try again anytime.");
822
+ window.history.replaceState({}, "", window.location.pathname);
823
+ }
824
+
825
+ // Greeting + conversation starters when there's no prior conversation.
826
+ showEmptyState();
827
+
828
+ // Focus the composer on desktop (not touch, to avoid forcing the keyboard up).
829
+ if (window.matchMedia && window.matchMedia("(hover: hover)").matches) {
830
+ messageInput.focus();
831
+ }
832
+ });