PageSourceSearch

https://www.housingassembly.org.za/js/main.js

js housingassembly.org.za collected 2026-10-07 02:09:00 UTC 13,051 bytes, 386 lines download raw bytes

1/* ==========================================================================
2   Site interactions.
3   Loaded after components.js so the injected header already exists.
4   ========================================================================== */
5
6function setupMobileNav() {
7  const toggle = document.querySelector(".nav-toggle");
8  const nav = document.getElementById("primary-nav");
9
10  if (!toggle || !nav) return;
11
12  toggle.addEventListener("click", () => {
13    const isOpen = nav.classList.toggle("is-open");
14    toggle.setAttribute("aria-expanded", String(isOpen));
15  });
16
17  /* Collapse the menu after following a link on mobile. */
18  nav.addEventListener("click", (event) => {
19    if (event.target.matches(".nav__link")) {
20      nav.classList.remove("is-open");
21      toggle.setAttribute("aria-expanded", "false");
22    }
23  });
24}
25
26function setupChatbot() {
27  const toggle = document.getElementById("chatbot-toggle");
28  const close = document.getElementById("chat-close");
29  const chatWindow = document.getElementById("chat-window");
30
31  if (!toggle || !chatWindow) return;
32
33  const isOpen = () => chatWindow.classList.contains("is-active");
34
35  const toggleChat = () => {
36    const isActive = chatWindow.classList.toggle("is-active");
37    toggle.setAttribute("aria-expanded", String(isActive));
38    chatWindow.setAttribute("aria-hidden", String(!isActive));
39    /* Collapse the pill into the small circle while the chat is open; restore
40       the full pill once it's closed. */
41    toggle.classList.toggle("is-collapsed", isActive);
42  };
43
44  toggle.addEventListener("click", toggleChat);
45  close?.addEventListener("click", toggleChat);
46
47  // Close on Escape key
48  document.addEventListener("keydown", (e) => {
49    if (e.key === "Escape" && isOpen()) toggleChat();
50  });
51
52  setupSpeechToText(chatWindow);
53  setupChatMessaging(chatWindow);
54}
55
56/* Replace with the real n8n webhook URL once it's set up. */
57const N8N_WEBHOOK_URL = "https://n8n-production-3a960.up.railway.app/webhook/ff78618a-90f7-474a-a205-2c718800f7dd";
58
59/* sessionStorage keys: continuity lasts for the browsing session (across page
60   navigations in the same tab) and clears when the tab is closed. */
61const SESSION_ID_KEY = "michael-blake-session";
62const TRANSCRIPT_KEY = "michael-blake-transcript";
63const UPDATED_AT_KEY = "michael-blake-updated";
64
65/* Reset the conversation after this much idle time so old chats don't reappear. */
66const IDLE_TTL_MS = 30 * 60 * 1000;
67
68/* If the last activity is older than the TTL, wipe the session id and transcript
69   so both the UI and n8n's memory (keyed by session id) start fresh. */
70function expireIfStale() {
71  const last = Number(sessionStorage.getItem(UPDATED_AT_KEY) || 0);
72  if (last && Date.now() - last > IDLE_TTL_MS) {
73    sessionStorage.removeItem(SESSION_ID_KEY);
74    sessionStorage.removeItem(TRANSCRIPT_KEY);
75    sessionStorage.removeItem(UPDATED_AT_KEY);
76  }
77}
78
79/* Stable per-visitor id so n8n's memory can tie messages to one conversation. */
80function getSessionId() {
81  let id = sessionStorage.getItem(SESSION_ID_KEY);
82  if (!id) {
83    id = crypto.randomUUID
84      ? crypto.randomUUID()
85      : `${Date.now()}-${Math.random().toString(16).slice(2)}`;
86    sessionStorage.setItem(SESSION_ID_KEY, id);
87  }
88  return id;
89}
90
91function loadTranscript() {
92  try {
93    return JSON.parse(sessionStorage.getItem(TRANSCRIPT_KEY)) ?? [];
94  } catch {
95    return [];
96  }
97}
98
99function saveTranscript(history) {
100  sessionStorage.setItem(TRANSCRIPT_KEY, JSON.stringify(history));
101  sessionStorage.setItem(UPDATED_AT_KEY, String(Date.now()));
102}
103
104/* Typewriter reveal pacing: ~65 chars/sec, but never longer than
105   REVEAL_MAX_MS in total so long replies speed up instead of dragging. */
106const REVEAL_TICK_MS = 30;
107const REVEAL_MIN_CHARS_PER_TICK = 2;
108const REVEAL_MAX_MS = 6000;
109
110/* Gradually reveals an already-rendered bot bubble, character by character.
111   The bubble's HTML is final before this runs; only text nodes are blanked
112   and refilled, so the markdown structure never reflows or flickers.
113   Returns a finish() function that completes the reveal instantly, or null
114   if no animation was started (reduced motion / empty reply). */
115function revealBotReply(bubble, body) {
116  if (window.matchMedia?.("(prefers-reduced-motion: reduce)").matches) return null;
117
118  /* Text nodes in document order, each with its full text snapshotted and
119     the bubble's top-level block it belongs to (hidden until reached). */
120  const parts = [];
121  (function collect(node) {
122    for (const child of node.childNodes) {
123      if (child.nodeType === 3) {
124        let block = child;
125        while (block.parentNode !== bubble) block = block.parentNode;
126        parts.push({ node: child, fullText: child.nodeValue, block });
127      } else {
128        collect(child);
129      }
130    }
131  })(bubble);
132
133  const totalChars = parts.reduce((sum, p) => sum + p.fullText.length, 0);
134  if (totalChars === 0) return null;
135
136  parts.forEach((p) => {
137    p.node.nodeValue = "";
138    if (p.block !== p.node) p.block.hidden = true;
139  });
140  bubble.classList.add("chat-msg--revealing");
141
142  const charsPerTick = Math.max(
143    REVEAL_MIN_CHARS_PER_TICK,
144    Math.ceil((totalChars * REVEAL_TICK_MS) / REVEAL_MAX_MS)
145  );
146
147  let partIndex = 0;
148  let charIndex = 0;
149  let done = false;
150
151  /* Keep the newest text in view, but don't yank the user down if they
152     have scrolled up to read something earlier. */
153  const scrollIfPinned = () => {
154    if (body.scrollHeight - body.scrollTop - body.clientHeight < 48) {
155      body.scrollTop = body.scrollHeight;
156    }
157  };
158
159  const finish = () => {
160    if (done) return;
161    done = true;
162    clearInterval(timer);
163    parts.forEach((p) => {
164      p.node.nodeValue = p.fullText;
165      if (p.block !== p.node) p.block.hidden = false;
166    });
167    bubble.classList.remove("chat-msg--revealing");
168    body.scrollTop = body.scrollHeight;
169  };
170
171  const timer = setInterval(() => {
172    let budget = charsPerTick;
173    while (budget > 0 && partIndex < parts.length) {
174      const part = parts[partIndex];
175      if (charIndex === 0 && part.block !== part.node) part.block.hidden = false;
176      charIndex = Math.min(charIndex + budget, part.fullText.length);
177      budget -= charIndex - part.node.nodeValue.length;
178      part.node.nodeValue = part.fullText.slice(0, charIndex);
179      if (charIndex >= part.fullText.length) {
180        partIndex += 1;
181        charIndex = 0;
182      }
183    }
184    scrollIfPinned();
185    if (partIndex >= parts.length) finish();
186  }, REVEAL_TICK_MS);
187
188  return finish;
189}
190
191/* Sends typed/dictated messages to the n8n webhook and shows the reply. */
192function setupChatMessaging(chatWindow) {
193  const body = chatWindow.querySelector(".chat-window__body");
194  const input = chatWindow.querySelector(".chat-window__footer input");
195  const sendBtn = chatWindow.querySelector(".chat-window__send");
196
197  if (!body || !input || !sendBtn) return;
198
199  expireIfStale();
200  const sessionId = getSessionId();
201  const history = loadTranscript();
202
203  /* finish() of the reply currently being revealed, if any. */
204  let finishActiveReveal = null;
205
206  /* Fill a bubble: user text stays literal (safe), while bot replies render
207     Markdown, sanitised with DOMPurify to strip unsafe HTML before it hits
208     the DOM. Falls back to plain text if the libraries fail to load. */
209  const setBubbleContent = (bubble, text, sender) => {
210    if (sender !== "user" && window.marked && window.DOMPurify) {
211      bubble.classList.add("chat-msg--rich");
212      bubble.innerHTML = window.DOMPurify.sanitize(
213        window.marked.parse(text, { breaks: true })
214      );
215    } else {
216      bubble.classList.remove("chat-msg--rich");
217      bubble.textContent = text;
218    }
219  };
220
221  /* Build a message bubble, add it to the chat, and keep the view scrolled down. */
222  const addMessage = (text, sender) => {
223    const bubble = document.createElement("div");
224    bubble.className = sender === "user" ? "chat-msg chat-msg--user" : "chat-msg";
225    setBubbleContent(bubble, text, sender);
226    body.appendChild(bubble);
227    body.scrollTop = body.scrollHeight;
228    return bubble;
229  };
230
231  /* Persist a finalised message so it reappears after navigating pages. */
232  const record = (text, sender) => {
233    history.push({ text, sender });
234    saveTranscript(history);
235  };
236
237  // Replay this session's earlier messages below the static welcome line.
238  history.forEach((entry) => addMessage(entry.text, entry.sender));
239
240  const handleSend = async () => {
241    const message = input.value.trim();
242    if (message === "") return;
243
244    // A new message completes any still-animating reply instantly.
245    if (finishActiveReveal) finishActiveReveal();
246    finishActiveReveal = null;
247
248    addMessage(message, "user");
249    record(message, "user");
250    input.value = "";
251    input.focus();
252
253    // Temporary placeholder shown until the webhook responds.
254    const pending = addMessage("Michael Blake is typing…", "bot");
255    pending.classList.add("chat-msg--pending");
256
257    try {
258      const response = await fetch(N8N_WEBHOOK_URL, {
259        method: "POST",
260        headers: { "Content-Type": "application/json" },
261        body: JSON.stringify({ message, sessionId }),
262      });
263
264      if (!response.ok) throw new Error(`Request failed: ${response.status}`);
265
266      const data = await response.json();
267      const reply = data.reply ?? "Sorry, I didn't get a response.";
268      pending.classList.remove("chat-msg--pending");
269      setBubbleContent(pending, reply, "bot");
270      record(reply, "bot");
271      finishActiveReveal = revealBotReply(pending, body);
272    } catch (error) {
273      pending.classList.remove("chat-msg--pending");
274      pending.textContent = "Sorry, something went wrong. Please try again.";
275    } finally {
276      body.scrollTop = body.scrollHeight;
277    }
278  };
279
280  sendBtn.addEventListener("click", handleSend);
281
282  input.addEventListener("keydown", (e) => {
283    if (e.key === "Enter") {
284      e.preventDefault();
285      handleSend();
286    }
287  });
288}
289
290/* Speech-to-text: dictate into the chat input using the Web Speech API. */
291function setupSpeechToText(chatWindow) {
292  const mic = document.getElementById("chat-mic");
293  const input = chatWindow.querySelector(".chat-window__footer input");
294
295  if (!mic || !input) return;
296
297  const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
298
299  // Hide the button gracefully if the browser doesn't support speech recognition.
300  if (!SpeechRecognition) {
301    mic.style.display = "none";
302    return;
303  }
304
305  const recognition = new SpeechRecognition();
306  recognition.lang = "en-US";
307  /* Keep listening through short pauses so speech isn't cut off mid-sentence,
308     and stream interim results so we can preview text live. */
309  recognition.continuous = true;
310  recognition.interimResults = true;
311  recognition.maxAlternatives = 1;
312
313  let listening = false;
314  /* Text already in the box before dictation started; dictation appends to it. */
315  let baseText = "";
316  /* Finalised chunks are banked here so they're never lost, even as newer
317     interim results come and go. */
318  let finalTranscript = "";
319
320  mic.addEventListener("click", () => {
321    if (listening) {
322      /* Stop now; the `end` handler commits whatever was captured. */
323      recognition.stop();
324      return;
325    }
326    try {
327      recognition.start();
328    } catch {
329      /* start() throws if called while already starting — safe to ignore. */
330    }
331  });
332
333  recognition.addEventListener("start", () => {
334    listening = true;
335    baseText = input.value.trim();
336    finalTranscript = "";
337    mic.classList.add("is-listening");
338    mic.setAttribute("aria-label", "Stop listening");
339  });
340
341  const reset = () => {
342    listening = false;
343    mic.classList.remove("is-listening");
344    mic.setAttribute("aria-label", "Speak your message");
345  };
346
347  /* Combine the base text with the finalised + interim speech and show it,
348     keeping the input scrolled to the end so the newest words stay visible. */
349  const render = (interim = "") => {
350    const spoken = (finalTranscript + interim).trim();
351    input.value = baseText && spoken ? `${baseText} ${spoken}` : baseText || spoken;
352    input.scrollLeft = input.scrollWidth;
353  };
354
355  recognition.addEventListener("end", () => {
356    render();
357    input.focus();
358    reset();
359  });
360
361  recognition.addEventListener("error", (event) => {
362    reset();
363    if (event.error === "not-allowed") {
364      alert("Microphone permission was denied. Please allow microphone access in your browser settings.");
365    } else if (event.error !== "no-speech") {
366      alert("Speech recognition failed. Please try typing instead.");
367    }
368  });
369
370  recognition.addEventListener("result", (event) => {
371    let interim = "";
372    /* Bank any newly finalised chunks; collect the rest as live interim text. */
373    for (let i = event.resultIndex; i < event.results.length; i++) {
374      const chunk = event.results[i][0].transcript;
375      if (event.results[i].isFinal) {
376        finalTranscript += chunk + " ";
377      } else {
378        interim += chunk;
379      }
380    }
381    render(interim);
382  });
383}
384
385setupMobileNav();
386setupChatbot();

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.