Glitche commited on
Upload 3 files
Browse files- static/js/patient.js +300 -0
- static/js/provider.js +179 -0
- static/js/shared.js +42 -0
static/js/patient.js
ADDED
|
@@ -0,0 +1,300 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// TalkToDoc patient page logic.
|
| 2 |
+
// Handles the three-step flow: language selection, input (text, quick
|
| 3 |
+
// chips, or voice), and checking for the provider's response.
|
| 4 |
+
|
| 5 |
+
(function () {
|
| 6 |
+
let selectedLanguage = null;
|
| 7 |
+
let mediaRecorder = null;
|
| 8 |
+
let recordedChunks = [];
|
| 9 |
+
let recordedBlob = null;
|
| 10 |
+
let currentInteractionId = null;
|
| 11 |
+
let recordingTimerInterval = null;
|
| 12 |
+
let recordingSeconds = 0;
|
| 13 |
+
|
| 14 |
+
const statusBanner = document.getElementById("status-banner");
|
| 15 |
+
const languageGrid = document.getElementById("language-grid");
|
| 16 |
+
const toStep2Btn = document.getElementById("to-step-2");
|
| 17 |
+
const backToStep1Btn = document.getElementById("back-to-step-1");
|
| 18 |
+
const submitInputBtn = document.getElementById("submit-input");
|
| 19 |
+
const recordBtn = document.getElementById("record-btn");
|
| 20 |
+
const stopRecordingBtn = document.getElementById("stop-recording-btn");
|
| 21 |
+
const inputPanel = document.getElementById("input-panel");
|
| 22 |
+
const listeningPanel = document.getElementById("listening-panel");
|
| 23 |
+
const recordTimer = document.getElementById("record-timer");
|
| 24 |
+
const recordStatus = document.getElementById("record-status");
|
| 25 |
+
const symptomChips = document.getElementById("symptom-chips");
|
| 26 |
+
const checkResponseBtn = document.getElementById("check-response-btn");
|
| 27 |
+
const newConversationBtn = document.getElementById("new-conversation-btn");
|
| 28 |
+
const historyCard = document.getElementById("history-card");
|
| 29 |
+
const historyList = document.getElementById("history-list");
|
| 30 |
+
const chatThread = document.getElementById("chat-thread");
|
| 31 |
+
const waitingText = document.getElementById("waiting-text");
|
| 32 |
+
const toast = document.getElementById("toast");
|
| 33 |
+
|
| 34 |
+
function showStep(stepNumber) {
|
| 35 |
+
document.querySelectorAll(".step").forEach(function (section) {
|
| 36 |
+
section.classList.toggle("active", section.dataset.step === String(stepNumber));
|
| 37 |
+
});
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
function showError(message) {
|
| 41 |
+
statusBanner.innerHTML = '<div class="status-banner error">' + escapeHtml(message) + "</div>";
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
function clearBanner() {
|
| 45 |
+
statusBanner.innerHTML = "";
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
function escapeHtml(text) {
|
| 49 |
+
const div = document.createElement("div");
|
| 50 |
+
div.textContent = text == null ? "" : String(text);
|
| 51 |
+
return div.innerHTML;
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
function showToast(message) {
|
| 55 |
+
toast.textContent = message;
|
| 56 |
+
toast.classList.add("visible");
|
| 57 |
+
setTimeout(function () {
|
| 58 |
+
toast.classList.remove("visible");
|
| 59 |
+
}, 2200);
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
function setButtonLoading(button, isLoading, loadingText) {
|
| 63 |
+
const spinner = button.querySelector(".btn-spinner");
|
| 64 |
+
const icon = button.querySelector(".btn-icon");
|
| 65 |
+
const label = button.querySelector(".btn-label");
|
| 66 |
+
button.disabled = isLoading;
|
| 67 |
+
if (spinner) spinner.style.display = isLoading ? "inline-block" : "none";
|
| 68 |
+
if (icon) icon.style.display = isLoading ? "none" : "inline-flex";
|
| 69 |
+
if (label && loadingText) label.textContent = isLoading ? loadingText : label.dataset.defaultText;
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
document.querySelectorAll(".btn-label").forEach(function (el) {
|
| 73 |
+
el.dataset.defaultText = el.textContent;
|
| 74 |
+
});
|
| 75 |
+
|
| 76 |
+
// Step 1: language selection
|
| 77 |
+
languageGrid.addEventListener("click", function (event) {
|
| 78 |
+
const option = event.target.closest(".language-option");
|
| 79 |
+
if (!option) return;
|
| 80 |
+
|
| 81 |
+
languageGrid.querySelectorAll(".language-option").forEach(function (el) {
|
| 82 |
+
el.setAttribute("aria-pressed", "false");
|
| 83 |
+
});
|
| 84 |
+
option.setAttribute("aria-pressed", "true");
|
| 85 |
+
selectedLanguage = option.dataset.language;
|
| 86 |
+
toStep2Btn.disabled = false;
|
| 87 |
+
});
|
| 88 |
+
|
| 89 |
+
toStep2Btn.addEventListener("click", function () {
|
| 90 |
+
showStep(2);
|
| 91 |
+
});
|
| 92 |
+
|
| 93 |
+
backToStep1Btn.addEventListener("click", function () {
|
| 94 |
+
showStep(1);
|
| 95 |
+
});
|
| 96 |
+
|
| 97 |
+
// Quick symptom chips: fill the textarea, patient can still edit before sending
|
| 98 |
+
symptomChips.addEventListener("click", function (event) {
|
| 99 |
+
const chip = event.target.closest(".chip");
|
| 100 |
+
if (!chip) return;
|
| 101 |
+
const textarea = document.getElementById("patient-text");
|
| 102 |
+
textarea.value = textarea.value ? textarea.value + ". " + chip.dataset.text : chip.dataset.text;
|
| 103 |
+
textarea.focus();
|
| 104 |
+
});
|
| 105 |
+
|
| 106 |
+
function formatTimer(totalSeconds) {
|
| 107 |
+
const minutes = Math.floor(totalSeconds / 60);
|
| 108 |
+
const seconds = totalSeconds % 60;
|
| 109 |
+
return minutes + ":" + String(seconds).padStart(2, "0");
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
function startRecording() {
|
| 113 |
+
recordBtn.classList.add("tapped");
|
| 114 |
+
setTimeout(function () { recordBtn.classList.remove("tapped"); }, 500);
|
| 115 |
+
|
| 116 |
+
navigator.mediaDevices.getUserMedia({ audio: true }).then(function (stream) {
|
| 117 |
+
recordedChunks = [];
|
| 118 |
+
mediaRecorder = new MediaRecorder(stream);
|
| 119 |
+
|
| 120 |
+
mediaRecorder.addEventListener("dataavailable", function (event) {
|
| 121 |
+
if (event.data.size > 0) recordedChunks.push(event.data);
|
| 122 |
+
});
|
| 123 |
+
|
| 124 |
+
mediaRecorder.addEventListener("stop", function () {
|
| 125 |
+
recordedBlob = new Blob(recordedChunks, { type: "audio/webm" });
|
| 126 |
+
stream.getTracks().forEach(function (track) { track.stop(); });
|
| 127 |
+
clearInterval(recordingTimerInterval);
|
| 128 |
+
inputPanel.style.display = "block";
|
| 129 |
+
listeningPanel.style.display = "none";
|
| 130 |
+
recordStatus.textContent = "Recording captured, ready to send.";
|
| 131 |
+
});
|
| 132 |
+
|
| 133 |
+
mediaRecorder.start();
|
| 134 |
+
recordingSeconds = 0;
|
| 135 |
+
recordTimer.textContent = "\u00A00:00";
|
| 136 |
+
inputPanel.style.display = "none";
|
| 137 |
+
listeningPanel.style.display = "block";
|
| 138 |
+
recordingTimerInterval = setInterval(function () {
|
| 139 |
+
recordingSeconds += 1;
|
| 140 |
+
recordTimer.textContent = "\u00A0" + formatTimer(recordingSeconds);
|
| 141 |
+
}, 1000);
|
| 142 |
+
}).catch(function () {
|
| 143 |
+
showError("Couldn't access your microphone. You can type your message instead.");
|
| 144 |
+
});
|
| 145 |
+
}
|
| 146 |
+
|
| 147 |
+
recordBtn.addEventListener("click", startRecording);
|
| 148 |
+
|
| 149 |
+
stopRecordingBtn.addEventListener("click", function () {
|
| 150 |
+
if (mediaRecorder && mediaRecorder.state === "recording") {
|
| 151 |
+
mediaRecorder.stop();
|
| 152 |
+
}
|
| 153 |
+
});
|
| 154 |
+
|
| 155 |
+
// Step 2 submit
|
| 156 |
+
submitInputBtn.addEventListener("click", async function () {
|
| 157 |
+
clearBanner();
|
| 158 |
+
const text = document.getElementById("patient-text").value.trim();
|
| 159 |
+
|
| 160 |
+
if (!text && !recordedBlob) {
|
| 161 |
+
showError("Type a message, tap a quick option, or record your voice first.");
|
| 162 |
+
return;
|
| 163 |
+
}
|
| 164 |
+
|
| 165 |
+
setButtonLoading(submitInputBtn, true, "Sending...");
|
| 166 |
+
|
| 167 |
+
const formData = new FormData();
|
| 168 |
+
formData.append("language", selectedLanguage);
|
| 169 |
+
if (recordedBlob) {
|
| 170 |
+
formData.append("audio", recordedBlob, "recording.webm");
|
| 171 |
+
} else {
|
| 172 |
+
formData.append("text", text);
|
| 173 |
+
}
|
| 174 |
+
|
| 175 |
+
try {
|
| 176 |
+
const response = await fetch("/patient-input", { method: "POST", body: formData });
|
| 177 |
+
const data = await response.json();
|
| 178 |
+
|
| 179 |
+
if (!response.ok) {
|
| 180 |
+
showError(data.error || "Something went wrong, please try again.");
|
| 181 |
+
return;
|
| 182 |
+
}
|
| 183 |
+
|
| 184 |
+
currentInteractionId = data.interaction_id;
|
| 185 |
+
document.getElementById("interaction-id-display").textContent = currentInteractionId;
|
| 186 |
+
document.getElementById("heard-text").textContent = data.patient_text;
|
| 187 |
+
|
| 188 |
+
// Reset step 3 to just the patient's own message, in case this
|
| 189 |
+
// page is reused for a second message later in the same session.
|
| 190 |
+
chatThread.querySelectorAll(".chat-bubble.from-provider").forEach(function (el) { el.remove(); });
|
| 191 |
+
document.getElementById("response-card").style.display = "none";
|
| 192 |
+
waitingText.style.display = "block";
|
| 193 |
+
checkResponseBtn.style.display = "inline-flex";
|
| 194 |
+
|
| 195 |
+
showStep(3);
|
| 196 |
+
loadHistory();
|
| 197 |
+
} catch (error) {
|
| 198 |
+
showError("Couldn't reach the server. Check your connection and try again.");
|
| 199 |
+
} finally {
|
| 200 |
+
setButtonLoading(submitInputBtn, false, "Sending...");
|
| 201 |
+
}
|
| 202 |
+
});
|
| 203 |
+
|
| 204 |
+
// Step 3: check for response
|
| 205 |
+
checkResponseBtn.addEventListener("click", async function () {
|
| 206 |
+
if (!currentInteractionId) return;
|
| 207 |
+
|
| 208 |
+
clearBanner();
|
| 209 |
+
setButtonLoading(checkResponseBtn, true, "Checking...");
|
| 210 |
+
|
| 211 |
+
try {
|
| 212 |
+
const response = await fetch("/interaction/" + currentInteractionId);
|
| 213 |
+
const data = await response.json();
|
| 214 |
+
|
| 215 |
+
if (data.translated_response) {
|
| 216 |
+
const bubble = document.createElement("div");
|
| 217 |
+
bubble.className = "chat-bubble from-provider list-item-in";
|
| 218 |
+
bubble.innerHTML = '<span class="chat-label">Provider</span>' + escapeHtml(data.translated_response);
|
| 219 |
+
chatThread.appendChild(bubble);
|
| 220 |
+
|
| 221 |
+
waitingText.style.display = "none";
|
| 222 |
+
checkResponseBtn.style.display = "none";
|
| 223 |
+
|
| 224 |
+
if (data.audio_url) {
|
| 225 |
+
document.getElementById("response-card").style.display = "block";
|
| 226 |
+
document.getElementById("response-audio").src = data.audio_url;
|
| 227 |
+
}
|
| 228 |
+
} else {
|
| 229 |
+
showError("No reply yet, try again in a moment.");
|
| 230 |
+
}
|
| 231 |
+
} catch (error) {
|
| 232 |
+
showError("Couldn't reach the server. Check your connection and try again.");
|
| 233 |
+
} finally {
|
| 234 |
+
setButtonLoading(checkResponseBtn, false, "Checking...");
|
| 235 |
+
}
|
| 236 |
+
});
|
| 237 |
+
|
| 238 |
+
// Past messages for this session
|
| 239 |
+
async function loadHistory() {
|
| 240 |
+
historyCard.style.display = "block";
|
| 241 |
+
historyList.innerHTML =
|
| 242 |
+
'<div class="skeleton-row"></div><div class="skeleton-row"></div>';
|
| 243 |
+
|
| 244 |
+
try {
|
| 245 |
+
const response = await fetch("/history");
|
| 246 |
+
const items = await response.json();
|
| 247 |
+
|
| 248 |
+
if (!items.length) {
|
| 249 |
+
historyCard.style.display = "none";
|
| 250 |
+
return;
|
| 251 |
+
}
|
| 252 |
+
|
| 253 |
+
historyList.innerHTML = "";
|
| 254 |
+
items.forEach(function (item, index) {
|
| 255 |
+
const row = document.createElement("div");
|
| 256 |
+
row.className = "result-block list-item-in";
|
| 257 |
+
row.style.animationDelay = (index * 40) + "ms";
|
| 258 |
+
const answered = Boolean(item.translated_response);
|
| 259 |
+
const badgeClass = answered ? "answered" : "waiting";
|
| 260 |
+
const badgeLabel = answered ? "Answered" : "Waiting for reply";
|
| 261 |
+
const timeLabel = window.formatRelativeTime ? window.formatRelativeTime(item.timestamp) : "";
|
| 262 |
+
row.innerHTML =
|
| 263 |
+
'<div class="result-label">#' + escapeHtml(item.id) +
|
| 264 |
+
' <span class="status-badge ' + badgeClass + '">' + badgeLabel + "</span>" +
|
| 265 |
+
' <span class="timestamp">' + escapeHtml(timeLabel) + "</span></div>" +
|
| 266 |
+
'<div class="result-text">' + escapeHtml(item.input_text) + "</div>";
|
| 267 |
+
historyList.appendChild(row);
|
| 268 |
+
});
|
| 269 |
+
historyCard.style.display = "block";
|
| 270 |
+
} catch (error) {
|
| 271 |
+
historyCard.style.display = "none";
|
| 272 |
+
}
|
| 273 |
+
}
|
| 274 |
+
|
| 275 |
+
// Start a new conversation: ends the current session server-side and
|
| 276 |
+
// resets the page back to language selection.
|
| 277 |
+
newConversationBtn.addEventListener("click", async function () {
|
| 278 |
+
try {
|
| 279 |
+
await fetch("/end-session", { method: "POST" });
|
| 280 |
+
} catch (error) {
|
| 281 |
+
// Even if this fails, still reset the page locally.
|
| 282 |
+
}
|
| 283 |
+
|
| 284 |
+
selectedLanguage = null;
|
| 285 |
+
recordedBlob = null;
|
| 286 |
+
currentInteractionId = null;
|
| 287 |
+
document.getElementById("patient-text").value = "";
|
| 288 |
+
document.getElementById("response-card").style.display = "none";
|
| 289 |
+
languageGrid.querySelectorAll(".language-option").forEach(function (el) {
|
| 290 |
+
el.setAttribute("aria-pressed", "false");
|
| 291 |
+
});
|
| 292 |
+
toStep2Btn.disabled = true;
|
| 293 |
+
clearBanner();
|
| 294 |
+
showStep(1);
|
| 295 |
+
loadHistory();
|
| 296 |
+
showToast("New conversation started");
|
| 297 |
+
});
|
| 298 |
+
|
| 299 |
+
loadHistory();
|
| 300 |
+
})();
|
static/js/provider.js
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// TalkToDoc provider page logic.
|
| 2 |
+
// Lists interactions waiting for a response, and lets the provider reply
|
| 3 |
+
// to a selected one.
|
| 4 |
+
|
| 5 |
+
(function () {
|
| 6 |
+
let selectedInteractionId = null;
|
| 7 |
+
|
| 8 |
+
const statusBanner = document.getElementById("status-banner");
|
| 9 |
+
const pendingList = document.getElementById("pending-list");
|
| 10 |
+
const emptyPending = document.getElementById("empty-pending");
|
| 11 |
+
const queueHeading = document.getElementById("queue-heading");
|
| 12 |
+
const backToListBtn = document.getElementById("back-to-list");
|
| 13 |
+
const submitResponseBtn = document.getElementById("submit-response");
|
| 14 |
+
const toast = document.getElementById("toast");
|
| 15 |
+
|
| 16 |
+
function showStep(stepName) {
|
| 17 |
+
document.querySelectorAll(".step").forEach(function (section) {
|
| 18 |
+
section.classList.toggle("active", section.dataset.step === stepName);
|
| 19 |
+
});
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
function showError(message) {
|
| 23 |
+
statusBanner.innerHTML = '<div class="status-banner error">' + escapeHtml(message) + "</div>";
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
function clearBanner() {
|
| 27 |
+
statusBanner.innerHTML = "";
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
function escapeHtml(text) {
|
| 31 |
+
const div = document.createElement("div");
|
| 32 |
+
div.textContent = text == null ? "" : String(text);
|
| 33 |
+
return div.innerHTML;
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
function showToast(message) {
|
| 37 |
+
toast.textContent = message;
|
| 38 |
+
toast.classList.add("visible");
|
| 39 |
+
setTimeout(function () {
|
| 40 |
+
toast.classList.remove("visible");
|
| 41 |
+
}, 2200);
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
function setButtonLoading(button, isLoading, loadingText) {
|
| 45 |
+
const spinner = button.querySelector(".btn-spinner");
|
| 46 |
+
const icon = button.querySelector(".btn-icon");
|
| 47 |
+
const label = button.querySelector(".btn-label");
|
| 48 |
+
button.disabled = isLoading;
|
| 49 |
+
if (spinner) spinner.style.display = isLoading ? "inline-block" : "none";
|
| 50 |
+
if (icon) icon.style.display = isLoading ? "none" : "inline-flex";
|
| 51 |
+
if (label && loadingText) label.textContent = isLoading ? loadingText : label.dataset.defaultText;
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
document.querySelectorAll(".btn-label").forEach(function (el) {
|
| 55 |
+
el.dataset.defaultText = el.textContent;
|
| 56 |
+
});
|
| 57 |
+
|
| 58 |
+
async function loadPending() {
|
| 59 |
+
pendingList.innerHTML = '<div class="skeleton-row"></div><div class="skeleton-row"></div><div class="skeleton-row"></div>';
|
| 60 |
+
|
| 61 |
+
try {
|
| 62 |
+
const response = await fetch("/pending-interactions");
|
| 63 |
+
const interactions = await response.json();
|
| 64 |
+
|
| 65 |
+
pendingList.innerHTML = "";
|
| 66 |
+
const isEmpty = interactions.length === 0;
|
| 67 |
+
emptyPending.style.display = isEmpty ? "block" : "none";
|
| 68 |
+
|
| 69 |
+
const newHeadingText = isEmpty
|
| 70 |
+
? "All caught up"
|
| 71 |
+
: interactions.length === 1
|
| 72 |
+
? "1 message waiting for you"
|
| 73 |
+
: interactions.length + " messages waiting for you";
|
| 74 |
+
queueHeading.textContent = newHeadingText;
|
| 75 |
+
// Restart the count-change animation, changing textContent alone
|
| 76 |
+
// does not replay a CSS animation on its own.
|
| 77 |
+
queueHeading.style.animation = "none";
|
| 78 |
+
void queueHeading.offsetWidth;
|
| 79 |
+
queueHeading.style.animation = "";
|
| 80 |
+
|
| 81 |
+
interactions.forEach(function (item, index) {
|
| 82 |
+
const row = document.createElement("button");
|
| 83 |
+
row.type = "button";
|
| 84 |
+
row.className = "queue-card list-item-in";
|
| 85 |
+
row.style.animationDelay = (index * 50) + "ms";
|
| 86 |
+
const timeLabel = window.formatRelativeTime ? window.formatRelativeTime(item.timestamp) : "";
|
| 87 |
+
row.innerHTML =
|
| 88 |
+
'<span class="queue-avatar">' + escapeHtml(item.detected_language.charAt(0).toUpperCase()) + '</span>' +
|
| 89 |
+
'<span class="queue-body">' +
|
| 90 |
+
'<span class="queue-top-row">' +
|
| 91 |
+
'<span class="queue-lang">' + escapeHtml(item.detected_language) + '</span>' +
|
| 92 |
+
'<span class="queue-top-right">' +
|
| 93 |
+
'<span class="timestamp">' + escapeHtml(timeLabel) + '</span>' +
|
| 94 |
+
'<span class="status-badge waiting">#' + escapeHtml(item.id) + '</span>' +
|
| 95 |
+
'</span>' +
|
| 96 |
+
'</span>' +
|
| 97 |
+
'<span class="queue-preview">' + escapeHtml(item.translated_text) + '</span>' +
|
| 98 |
+
'</span>';
|
| 99 |
+
row.addEventListener("click", function () {
|
| 100 |
+
openInteraction(item);
|
| 101 |
+
});
|
| 102 |
+
pendingList.appendChild(row);
|
| 103 |
+
});
|
| 104 |
+
} catch (error) {
|
| 105 |
+
showError("Couldn't load pending messages. Check your connection.");
|
| 106 |
+
}
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
function openInteraction(item) {
|
| 110 |
+
clearBanner();
|
| 111 |
+
selectedInteractionId = item.id;
|
| 112 |
+
document.getElementById("detail-placeholder").style.display = "none";
|
| 113 |
+
const detailContent = document.getElementById("detail-content");
|
| 114 |
+
detailContent.style.display = "block";
|
| 115 |
+
detailContent.classList.remove("list-item-in");
|
| 116 |
+
void detailContent.offsetWidth;
|
| 117 |
+
detailContent.classList.add("list-item-in");
|
| 118 |
+
document.getElementById("respond-interaction-id").textContent = item.id;
|
| 119 |
+
document.getElementById("respond-language-label").textContent =
|
| 120 |
+
"Patient (" + item.detected_language.charAt(0).toUpperCase() + item.detected_language.slice(1) + ")";
|
| 121 |
+
document.getElementById("respond-translated-text").textContent = item.translated_text;
|
| 122 |
+
document.getElementById("respond-nlu-summary").textContent = item.nlu_summary || "No summary available.";
|
| 123 |
+
document.getElementById("response-text-input").value = "";
|
| 124 |
+
showStep("respond");
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
+
document.getElementById("quick-reply-chips").addEventListener("click", function (event) {
|
| 128 |
+
const chip = event.target.closest(".chip");
|
| 129 |
+
if (!chip) return;
|
| 130 |
+
const textarea = document.getElementById("response-text-input");
|
| 131 |
+
textarea.value = textarea.value ? textarea.value + " " + chip.dataset.text : chip.dataset.text;
|
| 132 |
+
textarea.focus();
|
| 133 |
+
});
|
| 134 |
+
|
| 135 |
+
backToListBtn.addEventListener("click", function () {
|
| 136 |
+
document.getElementById("detail-placeholder").style.display = "block";
|
| 137 |
+
document.getElementById("detail-content").style.display = "none";
|
| 138 |
+
showStep("list");
|
| 139 |
+
loadPending();
|
| 140 |
+
});
|
| 141 |
+
|
| 142 |
+
submitResponseBtn.addEventListener("click", async function () {
|
| 143 |
+
clearBanner();
|
| 144 |
+
const responseText = document.getElementById("response-text-input").value.trim();
|
| 145 |
+
|
| 146 |
+
if (!responseText) {
|
| 147 |
+
showError("Type a reply before sending.");
|
| 148 |
+
return;
|
| 149 |
+
}
|
| 150 |
+
|
| 151 |
+
setButtonLoading(submitResponseBtn, true, "Sending...");
|
| 152 |
+
|
| 153 |
+
const formData = new FormData();
|
| 154 |
+
formData.append("interaction_id", selectedInteractionId);
|
| 155 |
+
formData.append("response_text", responseText);
|
| 156 |
+
|
| 157 |
+
try {
|
| 158 |
+
const response = await fetch("/provider-response", { method: "POST", body: formData });
|
| 159 |
+
const data = await response.json();
|
| 160 |
+
|
| 161 |
+
if (!response.ok) {
|
| 162 |
+
showError(data.error || "Something went wrong, please try again.");
|
| 163 |
+
return;
|
| 164 |
+
}
|
| 165 |
+
|
| 166 |
+
showStep("list");
|
| 167 |
+
document.getElementById("detail-placeholder").style.display = "block";
|
| 168 |
+
document.getElementById("detail-content").style.display = "none";
|
| 169 |
+
loadPending();
|
| 170 |
+
showToast("Reply sent to patient");
|
| 171 |
+
} catch (error) {
|
| 172 |
+
showError("Couldn't reach the server. Check your connection and try again.");
|
| 173 |
+
} finally {
|
| 174 |
+
setButtonLoading(submitResponseBtn, false, "Sending...");
|
| 175 |
+
}
|
| 176 |
+
});
|
| 177 |
+
|
| 178 |
+
loadPending();
|
| 179 |
+
})();
|
static/js/shared.js
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// TalkToDoc shared page logic: offline/online banner and relative time
|
| 2 |
+
// formatting, used by both the patient and provider pages.
|
| 3 |
+
|
| 4 |
+
(function () {
|
| 5 |
+
function setupOfflineBanner() {
|
| 6 |
+
const banner = document.createElement("div");
|
| 7 |
+
banner.className = "offline-banner";
|
| 8 |
+
banner.id = "offline-banner";
|
| 9 |
+
banner.setAttribute("role", "status");
|
| 10 |
+
banner.setAttribute("aria-live", "polite");
|
| 11 |
+
banner.innerHTML = '<span class="offline-dot"></span><span>You\'re offline. We\'ll keep trying to reconnect.</span>';
|
| 12 |
+
|
| 13 |
+
const main = document.querySelector(".app-main");
|
| 14 |
+
if (main) main.insertBefore(banner, main.firstChild);
|
| 15 |
+
|
| 16 |
+
function updateStatus() {
|
| 17 |
+
banner.classList.toggle("visible", !navigator.onLine);
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
window.addEventListener("online", updateStatus);
|
| 21 |
+
window.addEventListener("offline", updateStatus);
|
| 22 |
+
updateStatus();
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
document.addEventListener("DOMContentLoaded", setupOfflineBanner);
|
| 26 |
+
|
| 27 |
+
// Turns an ISO timestamp into a short relative label, e.g. "2m ago".
|
| 28 |
+
window.formatRelativeTime = function (isoString) {
|
| 29 |
+
if (!isoString) return "";
|
| 30 |
+
const then = new Date(isoString).getTime();
|
| 31 |
+
const now = Date.now();
|
| 32 |
+
const diffSeconds = Math.max(0, Math.floor((now - then) / 1000));
|
| 33 |
+
|
| 34 |
+
if (diffSeconds < 60) return "just now";
|
| 35 |
+
const diffMinutes = Math.floor(diffSeconds / 60);
|
| 36 |
+
if (diffMinutes < 60) return diffMinutes + (diffMinutes === 1 ? "m ago" : "m ago");
|
| 37 |
+
const diffHours = Math.floor(diffMinutes / 60);
|
| 38 |
+
if (diffHours < 24) return diffHours + "h ago";
|
| 39 |
+
const diffDays = Math.floor(diffHours / 24);
|
| 40 |
+
return diffDays + (diffDays === 1 ? "d ago" : "d ago");
|
| 41 |
+
};
|
| 42 |
+
})();
|