| | |
| | const API_BASE = ""; |
| |
|
| | |
| | const form = document.getElementById("researchForm"); |
| | const categoryInput = document.getElementById("productCategory"); |
| | const descriptionInput = document.getElementById("productDescription"); |
| | const productUrlInput = document.getElementById("productUrl"); |
| | const conceptsCountInput = document.getElementById("conceptsCount"); |
| | const scrapeBtn = document.getElementById("scrapeBtn"); |
| | const scrapeBtnText = scrapeBtn.querySelector(".scrape-btn-text"); |
| | const scrapeBtnLoader = scrapeBtn.querySelector(".scrape-btn-loader"); |
| | const submitBtn = document.getElementById("submitBtn"); |
| | const btnText = submitBtn.querySelector(".btn-text"); |
| | const btnLoader = submitBtn.querySelector(".btn-loader"); |
| | const errorBanner = document.getElementById("errorBanner"); |
| | const resultsDiv = document.getElementById("results"); |
| | const toggleBtns = document.querySelectorAll(".toggle-btn"); |
| |
|
| | |
| | const multiselect = document.getElementById("audienceMultiselect"); |
| | const selectedContainer = document.getElementById("selectedAudiences"); |
| | const dropdown = document.getElementById("audienceDropdown"); |
| | const searchInput = document.getElementById("audienceSearch"); |
| | const optionsContainer = document.getElementById("audienceOptions"); |
| |
|
| | let selectedMethod = "gpt"; |
| | let selectedAudiences = []; |
| | let allAudiences = []; |
| |
|
| | |
| | async function loadAudiences() { |
| | try { |
| | const res = await fetch(`${API_BASE}/api/target-audiences`); |
| | if (!res.ok) throw new Error("Failed to load audiences"); |
| | const data = await res.json(); |
| | allAudiences = data.audiences; |
| | renderOptions(); |
| | renderSelected(); |
| | } catch (err) { |
| | console.error("Could not load audiences:", err); |
| | selectedContainer.innerHTML = |
| | '<span class="multiselect-placeholder">⚠ Could not load — is the backend running?</span>'; |
| | } |
| | } |
| |
|
| | |
| | function renderOptions(filter = "") { |
| | const filterLower = filter.toLowerCase(); |
| | const filtered = allAudiences.filter((a) => |
| | a.toLowerCase().includes(filterLower) |
| | ); |
| |
|
| | optionsContainer.innerHTML = filtered |
| | .map((a) => { |
| | const isSelected = selectedAudiences.includes(a); |
| | return ` |
| | <div class="multiselect-option ${isSelected ? "selected" : ""}" data-value="${escapeAttr(a)}"> |
| | <span class="check">${isSelected ? "✓" : ""}</span> |
| | <span>${escapeHtml(a)}</span> |
| | </div> |
| | `; |
| | }) |
| | .join(""); |
| |
|
| | optionsContainer.querySelectorAll(".multiselect-option").forEach((opt) => { |
| | opt.addEventListener("click", () => { |
| | const val = opt.dataset.value; |
| | if (selectedAudiences.includes(val)) { |
| | selectedAudiences = selectedAudiences.filter((a) => a !== val); |
| | } else { |
| | selectedAudiences.push(val); |
| | } |
| | renderOptions(searchInput.value); |
| | renderSelected(); |
| | }); |
| | }); |
| | } |
| |
|
| | function renderSelected() { |
| | if (selectedAudiences.length === 0) { |
| | selectedContainer.innerHTML = |
| | '<span class="multiselect-placeholder">Select target audiences…</span>'; |
| | return; |
| | } |
| |
|
| | selectedContainer.innerHTML = selectedAudiences |
| | .map( |
| | (a) => ` |
| | <span class="multiselect-tag"> |
| | ${escapeHtml(a)} |
| | <span class="multiselect-tag-remove" data-value="${escapeAttr(a)}">×</span> |
| | </span> |
| | ` |
| | ) |
| | .join(""); |
| |
|
| | selectedContainer.querySelectorAll(".multiselect-tag-remove").forEach((btn) => { |
| | btn.addEventListener("click", (e) => { |
| | e.stopPropagation(); |
| | const val = btn.dataset.value; |
| | selectedAudiences = selectedAudiences.filter((a) => a !== val); |
| | renderOptions(searchInput.value); |
| | renderSelected(); |
| | }); |
| | }); |
| | } |
| |
|
| | selectedContainer.addEventListener("click", () => { |
| | dropdown.classList.toggle("hidden"); |
| | }); |
| |
|
| | document.addEventListener("click", (e) => { |
| | if (!multiselect.contains(e.target)) { |
| | dropdown.classList.add("hidden"); |
| | } |
| | }); |
| |
|
| | searchInput.addEventListener("input", () => { |
| | renderOptions(searchInput.value); |
| | }); |
| |
|
| | dropdown.addEventListener("click", (e) => { |
| | e.stopPropagation(); |
| | }); |
| |
|
| | |
| | toggleBtns.forEach((btn) => { |
| | btn.addEventListener("click", () => { |
| | toggleBtns.forEach((b) => b.classList.remove("active")); |
| | btn.classList.add("active"); |
| | selectedMethod = btn.dataset.method; |
| | }); |
| | }); |
| |
|
| | |
| | scrapeBtn.addEventListener("click", async () => { |
| | const url = productUrlInput.value.trim(); |
| | |
| | if (!url) { |
| | showError("Please enter a product URL."); |
| | return; |
| | } |
| |
|
| | |
| | try { |
| | new URL(url); |
| | } catch (e) { |
| | showError("Please enter a valid URL."); |
| | return; |
| | } |
| |
|
| | hideError(); |
| | setScrapeLoading(true); |
| |
|
| | try { |
| | const res = await fetch(`${API_BASE}/api/scrape-product`, { |
| | method: "POST", |
| | headers: { "Content-Type": "application/json" }, |
| | body: JSON.stringify({ url }), |
| | }); |
| |
|
| | if (!res.ok) { |
| | const errorData = await res.json().catch(() => ({})); |
| | throw new Error(errorData.detail || "Failed to scrape product data."); |
| | } |
| |
|
| | const data = await res.json(); |
| |
|
| | |
| | if (data.category) { |
| | categoryInput.value = data.category; |
| | } |
| | if (data.description) { |
| | descriptionInput.value = data.description; |
| | } |
| | |
| | if (data.target_audience && data.target_audience.length > 0) { |
| | selectedAudiences = [...data.target_audience]; |
| | renderOptions(searchInput.value); |
| | renderSelected(); |
| | } |
| |
|
| | const audienceNote = (data.target_audience && data.target_audience.length > 0) |
| | ? ` Target audience filled (${data.target_audience.length} selected).` |
| | : ""; |
| | showError(`✓ Product data scraped successfully!${data.product_name ? ` Found: ${data.product_name}.` : ""}${audienceNote}`, "success"); |
| | |
| | |
| | productUrlInput.value = ""; |
| |
|
| | } catch (err) { |
| | showError(err.message || "Something went wrong while scraping the product."); |
| | } finally { |
| | setScrapeLoading(false); |
| | } |
| | }); |
| |
|
| | function setScrapeLoading(isLoading) { |
| | scrapeBtn.disabled = isLoading; |
| | scrapeBtnText.classList.toggle("hidden", isLoading); |
| | scrapeBtnLoader.classList.toggle("hidden", !isLoading); |
| | } |
| |
|
| | |
| | form.addEventListener("submit", async (e) => { |
| | e.preventDefault(); |
| | hideError(); |
| | hideResults(); |
| |
|
| | const count = Math.min(15, Math.max(1, parseInt(conceptsCountInput.value, 10) || 5)); |
| |
|
| | const payload = { |
| | target_audience: selectedAudiences, |
| | product_category: categoryInput.value.trim(), |
| | product_description: descriptionInput.value.trim(), |
| | count, |
| | method: selectedMethod, |
| | }; |
| |
|
| | if ( |
| | payload.target_audience.length === 0 || |
| | !payload.product_category || |
| | !payload.product_description |
| | ) { |
| | showError( |
| | "Please fill in all fields and select at least one target audience." |
| | ); |
| | return; |
| | } |
| |
|
| | setLoading(true); |
| |
|
| | try { |
| | const res = await fetch(`${API_BASE}/api/research`, { |
| | method: "POST", |
| | headers: { "Content-Type": "application/json" }, |
| | body: JSON.stringify(payload), |
| | }); |
| |
|
| | if (!res.ok) { |
| | const errData = await res.json().catch(() => ({})); |
| | const msg = Array.isArray(errData.detail) ? errData.detail.map((e) => e.msg || e).join("; ") : (errData.detail || "Server error"); |
| | throw new Error(msg); |
| | } |
| |
|
| | const data = await res.json(); |
| | renderResults(data.results, selectedMethod); |
| | } catch (err) { |
| | showError(err.message || "Something went wrong."); |
| | } finally { |
| | setLoading(false); |
| | } |
| | }); |
| |
|
| | |
| | |
| | |
| | function renderResults(audienceResults, method) { |
| | const badge = |
| | method === "gpt" |
| | ? `<span class="results-badge gpt">GPT</span>` |
| | : `<span class="results-badge claude">Claude</span>`; |
| |
|
| | let html = ` |
| | <div class="results-header"> |
| | <h2 class="results-title">Results</h2> |
| | ${badge} |
| | </div> |
| | <div class="audience-stack"> |
| | `; |
| |
|
| | audienceResults.forEach((group, groupIndex) => { |
| | html += ` |
| | <div class="audience-card ${groupIndex === 0 ? "open" : ""}"> |
| | <div class="audience-card-header"> |
| | <div class="audience-header-left"> |
| | <span>👥</span> |
| | <h3>${escapeHtml(group.target_audience)}</h3> |
| | </div> |
| | <span class="audience-chevron">▾</span> |
| | </div> |
| | |
| | <div class="audience-card-body"> |
| | `; |
| |
|
| | group.output.forEach((item, idx) => { |
| | html += ` |
| | <div class="trigger-card ${idx === 0 && groupIndex === 0 ? "open" : ""}"> |
| | <div class="trigger-header"> |
| | <div> |
| | <span class="trigger-label">Trigger ${idx + 1}</span> |
| | <h4 class="trigger-name">${escapeHtml(item.phsychologyTriggers)}</h4> |
| | </div> |
| | <span class="trigger-chevron">▾</span> |
| | </div> |
| | |
| | <div class="trigger-body"> |
| | <div class="trigger-section"> |
| | <p class="section-title">Ad Angles</p> |
| | <ul class="section-list"> |
| | ${item.angles.map((a) => `<li>${escapeHtml(a)}</li>`).join("")} |
| | </ul> |
| | </div> |
| | |
| | <div class="trigger-section"> |
| | <p class="section-title">Ad Concepts</p> |
| | <ul class="section-list"> |
| | ${item.concepts.map((c) => `<li>${escapeHtml(c)}</li>`).join("")} |
| | </ul> |
| | </div> |
| | </div> |
| | </div> |
| | `; |
| | }); |
| |
|
| | html += ` |
| | </div> |
| | </div> |
| | `; |
| | }); |
| |
|
| | html += `</div>`; |
| |
|
| | resultsDiv.innerHTML = html; |
| | resultsDiv.classList.remove("hidden"); |
| |
|
| | attachAccordionEvents(); |
| | } |
| |
|
| | |
| | function attachAccordionEvents() { |
| | document.querySelectorAll(".audience-card-header").forEach((header) => { |
| | header.addEventListener("click", () => { |
| | const card = header.parentElement; |
| | card.classList.toggle("open"); |
| | }); |
| | }); |
| |
|
| | document.querySelectorAll(".trigger-header").forEach((header) => { |
| | header.addEventListener("click", () => { |
| | const card = header.parentElement; |
| |
|
| | |
| | const siblings = card.parentElement.querySelectorAll(".trigger-card"); |
| | siblings.forEach((s) => { |
| | if (s !== card) s.classList.remove("open"); |
| | }); |
| |
|
| | card.classList.toggle("open"); |
| | }); |
| | }); |
| | } |
| |
|
| | |
| | function setLoading(isLoading) { |
| | submitBtn.disabled = isLoading; |
| | btnText.classList.toggle("hidden", isLoading); |
| | btnLoader.classList.toggle("hidden", !isLoading); |
| | } |
| |
|
| | function showError(msg, type = "error") { |
| | errorBanner.textContent = msg; |
| | errorBanner.classList.remove("hidden"); |
| | |
| | |
| | if (type === "success") { |
| | errorBanner.style.background = "rgba(16, 163, 127, 0.1)"; |
| | errorBanner.style.borderColor = "rgba(16, 163, 127, 0.3)"; |
| | errorBanner.style.color = "#10a37f"; |
| | } else { |
| | errorBanner.style.background = "rgba(232, 84, 84, 0.1)"; |
| | errorBanner.style.borderColor = "rgba(232, 84, 84, 0.3)"; |
| | errorBanner.style.color = "var(--danger)"; |
| | } |
| | } |
| |
|
| | function hideError() { |
| | errorBanner.classList.add("hidden"); |
| | } |
| |
|
| | function hideResults() { |
| | resultsDiv.classList.add("hidden"); |
| | } |
| |
|
| | function escapeHtml(str) { |
| | const div = document.createElement("div"); |
| | div.textContent = str; |
| | return div.innerHTML; |
| | } |
| |
|
| | function escapeAttr(str) { |
| | return str.replace(/"/g, """).replace(/'/g, "'"); |
| | } |
| |
|
| | loadAudiences(); |
| |
|