const chat = document.getElementById("chat"); const chatShell = document.querySelector(".chat-shell"); const input = document.getElementById("freeform"); const send = document.getElementById("send"); const resultsList = document.getElementById("results"); const resultsMeta = document.getElementById("results-meta"); const pageLabel = document.getElementById("page-label"); const prevPageBtn = document.getElementById("prev-page"); const nextPageBtn = document.getElementById("next-page"); const workspace = document.querySelector(".workspace"); const propertyModal = document.getElementById("property-modal"); const propertyModalClose = document.getElementById("property-modal-close"); const modalImage = document.getElementById("modal-image"); const modalPrevImage = document.getElementById("modal-prev-image"); const modalNextImage = document.getElementById("modal-next-image"); const modalImageCount = document.getElementById("modal-image-count"); const modalPrice = document.getElementById("modal-price"); const modalMeta = document.getElementById("modal-meta"); const modalDescription = document.getElementById("modal-description"); const modalDescriptionText = document.getElementById("modal-description-text"); const modalFeatures = document.getElementById("modal-features"); const modalRightmoveLink = document.getElementById("modal-rightmove-link"); const BUY_TOKENS_TRIGGER = "buy tokens"; const BUY_TOKENS_URL = "https://buy.stripe.com/9B63cv1EKbhBcbm9TC83C01"; const API_BASE_URL = (import.meta.env.VITE_API_BASE_URL || "").replace(/\/+$/, ""); const transcript = []; const answers = {}; function apiUrl(path) { if (API_BASE_URL) { return `${API_BASE_URL}${path}`; } return path; } function createFlow() { return [ { id: "location", role: "bot", text: "Where should the property be located?", choices: ["Anywhere in London", "Near a specific area", "Within X miles of somewhere"], }, { id: "budget", role: "bot", text: "What’s your budget range?", input: true, choices: ["£600k - £700k", "£700k - £800k", "£800k - £900k"], allowFreeformWithChoices: true, }, { id: "type", role: "bot", text: "What property type do you want?", choices: ["Flat", "Terraced", "Semi-Detached", "Detached"], multiSelect: true, }, { id: "beds", role: "bot", text: "How many bedrooms are you aiming for?", choices: ["Studio / 1", "2", "3", "4+", "Flexible"], }, { id: "wrap", role: "bot", text: "Perfect. Ready to run your search?", choices: ["Start my search", "Restart"], }, ]; } let flow = createFlow(); let stepIndex = 0; let awaitingInput = false; let chatMode = "flow"; let assistantThreadId = null; let assistantBusy = false; let activeFilters = {}; let currentPage = 1; let totalPages = 1; let totalResults = 0; let modalImages = []; let modalImageIndex = 0; const CHOICE_REVEAL_DELAY_MS = 220; const PLACEHOLDER_ROTATE_MS = 1400; let placeholderTimer = null; let placeholderIndex = 0; let audioContext = null; let audioUnlocked = false; function ensureAudioContext() { const AudioCtor = typeof window !== "undefined" ? window.AudioContext || window.webkitAudioContext : null; if (!audioContext && AudioCtor) { audioContext = new AudioCtor(); } return audioContext; } function playMessageSound(role) { const ctx = ensureAudioContext(); if (!ctx || !audioUnlocked || ctx.state !== "running") return; const now = ctx.currentTime; const osc = ctx.createOscillator(); const gain = ctx.createGain(); const filter = ctx.createBiquadFilter(); filter.type = "lowpass"; filter.frequency.value = role === "bot" ? 1450 : 1650; osc.type = "sine"; osc.frequency.setValueAtTime(role === "bot" ? 680 : 760, now); osc.frequency.exponentialRampToValueAtTime(role === "bot" ? 520 : 600, now + 0.11); gain.gain.setValueAtTime(0.0001, now); gain.gain.exponentialRampToValueAtTime(role === "bot" ? 0.03 : 0.022, now + 0.015); gain.gain.exponentialRampToValueAtTime(0.0001, now + 0.14); osc.connect(filter); filter.connect(gain); gain.connect(ctx.destination); osc.start(now); osc.stop(now + 0.15); } function unlockAudio() { const ctx = ensureAudioContext(); if (!ctx) return; if (ctx.state === "running") { audioUnlocked = true; return; } ctx .resume() .then(() => { audioUnlocked = true; }) .catch(() => {}); } function addMessage(role, text) { const bubble = document.createElement("div"); bubble.className = `message ${role}`; bubble.textContent = text; chat.appendChild(bubble); chat.scrollTop = chat.scrollHeight; playMessageSound(role); } function clearPlaceholderRotation() { if (placeholderTimer) { clearInterval(placeholderTimer); placeholderTimer = null; } placeholderIndex = 0; } function startPlaceholderRotation(placeholders) { if (!placeholders || placeholders.length === 0) return; clearPlaceholderRotation(); input.placeholder = placeholders[0]; placeholderTimer = setInterval(() => { placeholderIndex = (placeholderIndex + 1) % placeholders.length; input.placeholder = placeholders[placeholderIndex]; }, PLACEHOLDER_ROTATE_MS); } function expandChat() { if (!chatShell || !chatShell.classList.contains("compact")) return; chatShell.classList.remove("compact"); } function activateSplitView() { workspace?.classList.add("split-active"); document.body.classList.add("split-active-mode"); expandChat(); } function deactivateSplitView() { workspace?.classList.remove("split-active"); document.body.classList.remove("split-active-mode"); } function addChoices(choices, options = {}) { if (!choices) return; const { delayMs = 0 } = options; const row = document.createElement("div"); row.className = "choice-row"; choices.forEach((choice, index) => { const btn = document.createElement("button"); btn.className = "choice-btn"; btn.textContent = choice; btn.style.animationDelay = `${index * 80}ms`; btn.addEventListener("click", () => handleChoice(choice)); row.appendChild(btn); }); const mount = () => { chat.appendChild(row); chat.scrollTop = chat.scrollHeight; }; if (delayMs > 0) { setTimeout(mount, delayMs); return; } mount(); } function addCustomChoices(choices, onClick, options = {}) { if (!choices) return; const { delayMs = 0 } = options; const row = document.createElement("div"); row.className = "choice-row"; choices.forEach((choice, index) => { const btn = document.createElement("button"); btn.className = "choice-btn"; btn.textContent = choice; if (choice.trim().toLowerCase() === BUY_TOKENS_TRIGGER) { btn.classList.add("choice-btn-buy-tokens"); } btn.style.animationDelay = `${index * 80}ms`; btn.addEventListener("click", () => onClick(choice)); row.appendChild(btn); }); const mount = () => { chat.appendChild(row); chat.scrollTop = chat.scrollHeight; }; if (delayMs > 0) { setTimeout(mount, delayMs); return; } mount(); } function removeChoiceRows() { chat.querySelectorAll(".choice-row").forEach((row) => row.remove()); } function renderAssistantSuggestions(suggestions) { removeChoiceRows(); if (!Array.isArray(suggestions) || suggestions.length === 0) return; addCustomChoices( suggestions, async (choice) => { await sendRefineMessage(choice); }, { delayMs: CHOICE_REVEAL_DELAY_MS }, ); } function addMultiSelectChoices(step, options = {}) { const choices = step.choices; if (!choices) return; const { delayMs = 0 } = options; const selected = new Set(); const row = document.createElement("div"); row.className = "choice-row"; const helper = document.createElement("div"); helper.className = "choice-helper"; helper.textContent = "Select all that apply and click continue"; const continueBtn = document.createElement("button"); continueBtn.className = "choice-btn choice-btn-continue"; continueBtn.textContent = "Continue"; continueBtn.disabled = true; continueBtn.addEventListener("click", () => { if (selected.size === 0) return; helper.classList.remove("visible"); expandChat(); const values = Array.from(selected); const valueText = values.join(", "); addMessage("user", valueText); transcript.push({ role: "user", text: valueText }); answers[step.id] = values; stepIndex += 1; runStep(); }); choices.forEach((choice, index) => { const btn = document.createElement("button"); btn.className = "choice-btn"; btn.textContent = choice; btn.style.animationDelay = `${index * 80}ms`; btn.addEventListener("click", () => { unlockAudio(); if (selected.has(choice)) { selected.delete(choice); btn.classList.remove("selected"); } else { selected.add(choice); btn.classList.add("selected"); } continueBtn.disabled = selected.size === 0; if (selected.size > 0) { helper.classList.add("visible"); } else { helper.classList.remove("visible"); } }); row.appendChild(btn); }); continueBtn.style.animationDelay = `${choices.length * 80}ms`; row.appendChild(continueBtn); row.appendChild(helper); const mount = () => { chat.appendChild(row); chat.scrollTop = chat.scrollHeight; }; if (delayMs > 0) { setTimeout(mount, delayMs); return; } mount(); } function buildSearchFilters() { const propertyTypes = Array.isArray(answers.type) ? answers.type : typeof answers.type === "string" ? answers.type.split(",").map((value) => value.trim()).filter(Boolean) : []; return { budget: answers.budget || null, beds: answers.beds || null, propertyTypes, }; } function clearResults() { resultsList.innerHTML = ""; } function scrollResultsToTop() { if (!resultsList) return; resultsList.scrollTop = 0; } function renderResults(properties) { clearResults(); if (!properties || properties.length === 0) { const empty = document.createElement("div"); empty.className = "property-card"; empty.textContent = "No properties found for these filters."; resultsList.appendChild(empty); return; } properties.forEach((property) => { const card = document.createElement("article"); card.className = "property-card"; let media; if (property.imageUrl) { const image = document.createElement("img"); image.className = "property-thumb"; image.alt = property.title || "Property image"; image.loading = "lazy"; image.src = property.imageUrl; image.addEventListener("error", () => { image.replaceWith(createImageFallback()); }); media = image; } else { media = createImageFallback(); } const body = document.createElement("div"); body.className = "property-body"; const price = document.createElement("div"); price.className = "property-price"; price.textContent = property.price || "Price unavailable"; const meta = document.createElement("div"); meta.className = "property-meta"; const beds = property.bedrooms ? `${property.bedrooms} bed` : "Beds n/a"; const baths = property.bathrooms ? `${property.bathrooms} bath` : "Baths n/a"; const type = property.propertyType || "Type n/a"; const postcode = property.postcode || "Postcode n/a"; meta.textContent = `${beds} • ${baths} • ${type} • ${postcode}`; const snippet = document.createElement("p"); snippet.className = "property-snippet"; snippet.textContent = property.snippet || "No description snippet available."; body.appendChild(price); body.appendChild(meta); body.appendChild(snippet); card.appendChild(media); card.appendChild(body); card.addEventListener("click", () => openPropertyModal(property)); card.addEventListener("keydown", (event) => { if (event.key === "Enter") { openPropertyModal(property); } }); card.tabIndex = 0; resultsList.appendChild(card); }); } function createImageFallback() { const placeholder = document.createElement("div"); placeholder.className = "property-thumb property-thumb-fallback"; placeholder.textContent = "No image"; return placeholder; } function updatePager(page, pages, total) { currentPage = page; totalPages = pages; totalResults = total; pageLabel.textContent = `Page ${page} of ${pages}`; resultsMeta.textContent = `${total} results`; prevPageBtn.disabled = page <= 1; nextPageBtn.disabled = page >= pages; } function setModalImage(index) { if (!Array.isArray(modalImages) || modalImages.length === 0) { modalImage.src = ""; modalImage.alt = "No property image"; modalImageCount.textContent = "No images available"; modalPrevImage.disabled = true; modalNextImage.disabled = true; return; } modalImageIndex = (index + modalImages.length) % modalImages.length; const imageUrl = modalImages[modalImageIndex]; modalImage.src = imageUrl; modalImage.alt = `Property image ${modalImageIndex + 1}`; modalImageCount.textContent = `${modalImageIndex + 1} / ${modalImages.length}`; modalPrevImage.disabled = modalImages.length <= 1; modalNextImage.disabled = modalImages.length <= 1; } function closePropertyModal() { propertyModal.classList.add("hidden"); propertyModal.setAttribute("aria-hidden", "true"); modalImages = []; modalImageIndex = 0; } function renderModalFeatures(features) { modalFeatures.innerHTML = ""; if (!Array.isArray(features) || features.length === 0) return 0; let count = 0; features.slice(0, 16).forEach((feature) => { if (!feature || typeof feature !== "string") return; const chip = document.createElement("span"); chip.className = "modal-feature"; chip.textContent = feature; modalFeatures.appendChild(chip); count += 1; }); return count; } function toStringArray(value) { if (Array.isArray(value)) { return value.filter((item) => typeof item === "string" && item.trim()); } if (typeof value === "string" && value.trim()) { try { const parsed = JSON.parse(value); if (Array.isArray(parsed)) { return parsed.filter((item) => typeof item === "string" && item.trim()); } } catch { return []; } } return []; } async function openPropertyModal(propertyRef) { const rightmoveId = typeof propertyRef === "string" ? propertyRef : propertyRef?.rightmoveId; if (!rightmoveId) return; const fallbackPrice = typeof propertyRef === "object" ? propertyRef.price : ""; const fallbackMeta = typeof propertyRef === "object" ? [ propertyRef.bedrooms ? `${propertyRef.bedrooms} bed` : "Beds n/a", propertyRef.bathrooms ? `${propertyRef.bathrooms} bath` : "Baths n/a", propertyRef.propertyType || "Type n/a", propertyRef.postcode || "Postcode n/a", ].join(" • ") : ""; const fallbackDescription = typeof propertyRef === "object" ? propertyRef.snippet : ""; const fallbackImage = typeof propertyRef === "object" ? propertyRef.imageUrl : ""; let nextPrice = fallbackPrice || "Property details"; let nextMeta = fallbackMeta; let nextDescription = fallbackDescription || ""; let descriptionIsHtml = false; let nextFeatures = []; let nextRightmoveUrl = `https://www.rightmove.co.uk/properties/${rightmoveId}`; let nextImages = fallbackImage ? [fallbackImage] : []; try { const res = await fetch(apiUrl(`/api/property/${encodeURIComponent(rightmoveId)}`)); if (!res.ok) { throw new Error("Could not load property details."); } const detail = await res.json(); const beds = detail?.bedrooms ? `${detail.bedrooms} bed` : "Beds n/a"; const baths = detail?.bathrooms ? `${detail.bathrooms} bath` : "Baths n/a"; const type = detail?.propertyType || "Type n/a"; const postcode = detail?.postcode || "Postcode n/a"; const tenure = detail?.tenure ? ` • ${detail.tenure}` : ""; const councilTax = detail?.councilTaxBand ? ` • Council tax ${detail.councilTaxBand}` : ""; nextPrice = detail?.price || "Price unavailable"; nextMeta = `${beds} • ${baths} • ${type} • ${postcode}${tenure}${councilTax}`; nextDescription = detail?.description || "No description available."; descriptionIsHtml = true; nextRightmoveUrl = detail?.rightmoveUrl || nextRightmoveUrl; const imageUrls = toStringArray(detail?.imageUrls); const floorplans = toStringArray(detail?.floorplans); nextImages = [...imageUrls, ...floorplans].filter((url) => typeof url === "string" && url.trim()); nextFeatures = toStringArray(detail?.keyFeatures); } catch (error) { if (!nextDescription.trim()) { nextDescription = "More details are temporarily unavailable for this property."; } if (!nextMeta) { nextMeta = "Details unavailable"; } descriptionIsHtml = false; } modalPrice.textContent = nextPrice; modalMeta.textContent = nextMeta; if (descriptionIsHtml) { modalDescriptionText.innerHTML = nextDescription; } else { modalDescriptionText.textContent = nextDescription; } modalRightmoveLink.href = nextRightmoveUrl; renderModalFeatures(nextFeatures); modalImages = nextImages; setModalImage(0); propertyModal.classList.remove("hidden"); propertyModal.setAttribute("aria-hidden", "false"); } function buildShortlistState() { return { intake_answers: { location_mode: answers.location || null, location_detail: answers["location-near-detail"] || answers["location-radius-detail"] || null, budget: answers.budget || null, property_types: Array.isArray(answers.type) ? answers.type : typeof answers.type === "string" ? answers.type.split(",").map((value) => value.trim()).filter(Boolean) : [], bedrooms: answers.beds || null, }, current_results: { total_results: totalResults, current_page: currentPage, total_pages: totalPages, }, }; } async function fetchResults(page = 1) { activeFilters = buildSearchFilters(); resultsMeta.textContent = "Loading..."; try { const res = await fetch(apiUrl("/api/search"), { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ page, ...activeFilters, }), }); if (!res.ok) { let detail = "Search request failed."; try { const payload = await res.json(); detail = payload?.detail || payload?.error || detail; } catch { // ignore parse failures and keep generic detail } throw new Error(detail); } const data = await res.json(); scrollResultsToTop(); renderResults(data.properties || []); updatePager(data.page || 1, data.totalPages || 1, data.total || 0); return data; } catch (error) { scrollResultsToTop(); clearResults(); const errorCard = document.createElement("div"); errorCard.className = "property-card"; errorCard.textContent = `Could not load results from the property database. ${error?.message || ""}`.trim(); resultsList.appendChild(errorCard); updatePager(1, 1, 0); return null; } } function startRefineChat(totalResults) { chatMode = "refine"; chat.innerHTML = ""; transcript.length = 0; assistantThreadId = null; assistantBusy = false; clearPlaceholderRotation(); input.value = ""; input.placeholder = "Ask anything to narrow the list down"; awaitingInput = true; input.focus(); const firstMessage = `Woah! ${totalResults} results? That's going to take a while to work through! Let me know what you're looking for and I'll help you narrow the list down.`; const secondMessage = "Here are some suggestions that might help, but you can ask me anything!"; addMessage("bot", firstMessage); transcript.push({ role: "bot", text: firstMessage, id: "refine-welcome" }); addMessage("bot", secondMessage); transcript.push({ role: "bot", text: secondMessage, id: "refine-suggestions" }); renderAssistantSuggestions([ "Low crime area", "Close to excellent school", "Within 10 minutes walk to tube station", "Area of similar ethnicity to me", "Kitchen island", "South-facing garden", "I want a flat with dirty carpets", "I want a bigger kitchen than my friend's kitchen (upload photo)", "I want a house that looks like Homer Simpsons", "I don't want a flat that looks like someone has died there", "Don't show me renovation projects", "Prioritise properties with real fireplaces", ]); } async function sendRefineMessage(value) { if (assistantBusy) return; const message = typeof value === "string" ? value.trim() : ""; if (!message) return; if (message.toLowerCase() === BUY_TOKENS_TRIGGER) { window.location.href = BUY_TOKENS_URL; return; } unlockAudio(); addMessage("user", message); transcript.push({ role: "user", text: message }); input.value = ""; awaitingInput = true; assistantBusy = true; send.disabled = true; addMessage("bot", "Thinking..."); const thinkingBubble = chat.lastElementChild; try { const res = await fetch(apiUrl("/api/assistant-chat"), { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ message, threadId: assistantThreadId, shortlistState: buildShortlistState(), }), }); let payload = {}; let rawText = ""; try { payload = await res.json(); } catch { rawText = await res.text().catch(() => ""); } if (!res.ok) { throw new Error( payload?.detail || payload?.error || rawText || `Assistant request failed (${res.status})`, ); } if (thinkingBubble?.parentElement === chat) { thinkingBubble.remove(); } if (payload?.threadId) { assistantThreadId = payload.threadId; } const replyText = typeof payload?.reply === "string" && payload.reply.trim() ? payload.reply.trim() : "I can help you narrow your shortlist further."; addMessage("bot", replyText); transcript.push({ role: "bot", text: replyText, id: "assistant-reply" }); renderAssistantSuggestions(payload?.suggested_responses || []); } catch (error) { if (thinkingBubble?.parentElement === chat) { thinkingBubble.remove(); } const replyText = `I couldn't reach the assistant right now. ${error?.message || ""}`.trim(); addMessage("bot", replyText); transcript.push({ role: "bot", text: replyText, id: "assistant-error" }); } finally { assistantBusy = false; send.disabled = false; input.focus(); } } async function handleChoice(choice) { unlockAudio(); const step = flow[stepIndex]; if (step?.id === "location" && choice === "Near a specific area") { flow.splice(stepIndex + 1, 0, { id: "location-near-detail", role: "bot", text: "Which area would you like to be near?", input: true, placeholders: ["e.g. Clapham", "e.g. Wimbledon", "e.g. Canary Wharf"], }); } if (step?.id === "location" && choice === "Within X miles of somewhere") { flow.splice(stepIndex + 1, 0, { id: "location-radius-detail", role: "bot", text: "Which place should we use, and what mile radius?", input: true, placeholders: [ "e.g. Within 5 miles of Richmond", "e.g. Within 10 miles of Waterloo", "e.g. Within 8 miles of Canary Wharf", ], }); } expandChat(); addMessage("user", choice); transcript.push({ role: "user", text: choice }); if (step?.id) { answers[step.id] = choice; } if (choice === "Restart") { resetChat(); return; } if (choice === "Start my search") { activateSplitView(); const data = await fetchResults(1); startRefineChat(data?.total || 0); stepIndex += 1; return; } stepIndex += 1; runStep(); } function runStep() { const step = flow[stepIndex]; if (!step) return; addMessage(step.role, step.text); transcript.push({ role: step.role, text: step.text, id: step.id }); if (step.multiSelect && step.choices) { awaitingInput = false; clearPlaceholderRotation(); input.placeholder = ""; addMultiSelectChoices(step, { delayMs: CHOICE_REVEAL_DELAY_MS }); } else if (step.input && step.choices) { awaitingInput = Boolean(step.allowFreeformWithChoices); if (awaitingInput && Array.isArray(step.placeholders)) { startPlaceholderRotation(step.placeholders); } else { clearPlaceholderRotation(); input.placeholder = ""; } if (awaitingInput) input.focus(); addChoices(step.choices, { delayMs: CHOICE_REVEAL_DELAY_MS }); } else if (step.input) { awaitingInput = true; if (Array.isArray(step.placeholders)) { startPlaceholderRotation(step.placeholders); } else { clearPlaceholderRotation(); input.placeholder = ""; } input.focus(); addChoices([]); } else if (step.choices) { awaitingInput = false; clearPlaceholderRotation(); input.placeholder = ""; addChoices(step.choices, { delayMs: CHOICE_REVEAL_DELAY_MS }); } else { awaitingInput = false; clearPlaceholderRotation(); input.placeholder = ""; addChoices([]); } } function handleFreeform() { unlockAudio(); if (chatMode === "refine") { sendRefineMessage(input.value); return; } if (!awaitingInput) return; const value = input.value.trim(); if (!value) return; const step = flow[stepIndex]; expandChat(); addMessage("user", value); transcript.push({ role: "user", text: value }); if (step?.id) { answers[step.id] = value; } input.value = ""; awaitingInput = false; clearPlaceholderRotation(); stepIndex += 1; runStep(); } function resetChat() { chat.innerHTML = ""; chatMode = "flow"; assistantThreadId = null; assistantBusy = false; send.disabled = false; deactivateSplitView(); clearResults(); resultsMeta.textContent = ""; pageLabel.textContent = "Page 1 of 1"; prevPageBtn.disabled = true; nextPageBtn.disabled = true; flow = createFlow(); transcript.length = 0; Object.keys(answers).forEach((key) => { delete answers[key]; }); stepIndex = 0; awaitingInput = false; input.value = ""; input.placeholder = ""; clearPlaceholderRotation(); runStep(); } send.addEventListener("click", handleFreeform); input.addEventListener("keydown", (event) => { if (event.key === "Enter") { handleFreeform(); } }); prevPageBtn.addEventListener("click", () => { if (currentPage > 1) { fetchResults(currentPage - 1); } }); nextPageBtn.addEventListener("click", () => { if (currentPage < totalPages) { fetchResults(currentPage + 1); } }); modalPrevImage.addEventListener("click", () => { setModalImage(modalImageIndex - 1); }); modalNextImage.addEventListener("click", () => { setModalImage(modalImageIndex + 1); }); propertyModalClose.addEventListener("click", closePropertyModal); propertyModal.addEventListener("click", (event) => { const target = event.target; if (target instanceof HTMLElement && target.dataset.closeModal === "true") { closePropertyModal(); } }); window.addEventListener("pointerdown", unlockAudio, { once: true }); window.addEventListener("touchstart", unlockAudio, { once: true }); window.addEventListener("mousedown", unlockAudio, { once: true }); window.addEventListener("keydown", unlockAudio, { once: true }); window.addEventListener("keydown", (event) => { if (event.key === "Escape" && !propertyModal.classList.contains("hidden")) { closePropertyModal(); } }); resetChat();