1048 lines
45 KiB
JavaScript
1048 lines
45 KiB
JavaScript
/* ── TheNicheQuiz.com – Frontend Logic ─────────────────────────────────── */
|
||
|
||
let adCount = 1;
|
||
let previewAdIndex = 0;
|
||
let lastCSVData = null;
|
||
let lastExportCSVData = null;
|
||
|
||
// Quiz state
|
||
let quizState = {
|
||
step: 1,
|
||
industry: '',
|
||
subNiche: '',
|
||
microNiche: ''
|
||
};
|
||
|
||
// Parallel campaigns state
|
||
let parallelCampaigns = [];
|
||
let galleryMode = false;
|
||
|
||
// ── Save Campaign Set ───────────────────────────────────────────────────────
|
||
|
||
async function saveCampaignSet() {
|
||
if (parallelCampaigns.length === 0) {
|
||
showToast('No campaigns to save', 'error');
|
||
return;
|
||
}
|
||
|
||
const name = quizState.microNiche || quizState.subNiche || quizState.industry || 'Untitled Campaign';
|
||
|
||
try {
|
||
const res = await fetch('/api/save-campaign', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
name: name + ' — ' + new Date().toLocaleDateString(),
|
||
industry: quizState.industry,
|
||
sub_niche: quizState.subNiche,
|
||
micro_niche: quizState.microNiche,
|
||
campaign_data: { campaigns: parallelCampaigns }
|
||
})
|
||
});
|
||
const data = await res.json();
|
||
if (data.error) throw new Error(data.error);
|
||
showToast('Campaigns saved! View them in My Campaigns.', 'success');
|
||
} catch (e) {
|
||
showToast('Failed to save: ' + e.message, 'error');
|
||
}
|
||
}
|
||
|
||
// ── Particle Background ─────────────────────────────────────────────────────
|
||
|
||
(function initParticles() {
|
||
const canvas = document.getElementById('particleCanvas');
|
||
if (!canvas) return;
|
||
const ctx = canvas.getContext('2d');
|
||
let particles = [];
|
||
const PARTICLE_COUNT = 60;
|
||
|
||
function resize() {
|
||
canvas.width = window.innerWidth;
|
||
canvas.height = window.innerHeight;
|
||
}
|
||
resize();
|
||
window.addEventListener('resize', resize);
|
||
|
||
class Particle {
|
||
constructor() { this.reset(); }
|
||
reset() {
|
||
this.x = Math.random() * canvas.width;
|
||
this.y = Math.random() * canvas.height;
|
||
this.vx = (Math.random() - 0.5) * 0.3;
|
||
this.vy = (Math.random() - 0.5) * 0.3;
|
||
this.radius = Math.random() * 1.5 + 0.5;
|
||
this.alpha = Math.random() * 0.5 + 0.1;
|
||
const colors = ['245, 158, 11', '59, 130, 246', '168, 85, 247'];
|
||
this.color = colors[Math.floor(Math.random() * colors.length)];
|
||
}
|
||
update() {
|
||
this.x += this.vx;
|
||
this.y += this.vy;
|
||
if (this.x < 0 || this.x > canvas.width) this.vx *= -1;
|
||
if (this.y < 0 || this.y > canvas.height) this.vy *= -1;
|
||
}
|
||
draw() {
|
||
ctx.beginPath();
|
||
ctx.arc(this.x, this.y, this.radius, 0, Math.PI * 2);
|
||
ctx.fillStyle = `rgba(${this.color}, ${this.alpha})`;
|
||
ctx.fill();
|
||
}
|
||
}
|
||
|
||
for (let i = 0; i < PARTICLE_COUNT; i++) particles.push(new Particle());
|
||
|
||
function drawConnections() {
|
||
for (let i = 0; i < particles.length; i++) {
|
||
for (let j = i + 1; j < particles.length; j++) {
|
||
const dx = particles[i].x - particles[j].x;
|
||
const dy = particles[i].y - particles[j].y;
|
||
const dist = Math.sqrt(dx * dx + dy * dy);
|
||
if (dist < 150) {
|
||
ctx.beginPath();
|
||
ctx.moveTo(particles[i].x, particles[i].y);
|
||
ctx.lineTo(particles[j].x, particles[j].y);
|
||
ctx.strokeStyle = `rgba(255, 255, 255, ${0.03 * (1 - dist / 150)})`;
|
||
ctx.lineWidth = 0.5;
|
||
ctx.stroke();
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
function animate() {
|
||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||
particles.forEach(p => { p.update(); p.draw(); });
|
||
drawConnections();
|
||
requestAnimationFrame(animate);
|
||
}
|
||
animate();
|
||
})();
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
// NICHE QUIZ
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
|
||
function updateStepIndicator() {
|
||
const fill = document.getElementById('stepLineFill');
|
||
for (let i = 1; i <= 3; i++) {
|
||
const dot = document.getElementById(`stepDot${i}`);
|
||
dot.classList.remove('active', 'completed');
|
||
if (i < quizState.step) dot.classList.add('completed');
|
||
else if (i === quizState.step) dot.classList.add('active');
|
||
}
|
||
fill.style.width = ((quizState.step - 1) / 2 * 100) + '%';
|
||
}
|
||
|
||
function goToStep(step) {
|
||
quizState.step = step;
|
||
document.getElementById('quizStep1').classList.add('hidden');
|
||
document.getElementById('quizStep2').classList.add('hidden');
|
||
document.getElementById('quizStep3').classList.add('hidden');
|
||
document.getElementById('quizComplete').classList.add('hidden');
|
||
document.getElementById(`quizStep${step}`).classList.remove('hidden');
|
||
updateStepIndicator();
|
||
}
|
||
|
||
function selectIndustry(btn) {
|
||
document.querySelectorAll('.industry-card').forEach(c => c.classList.remove('selected'));
|
||
btn.classList.add('selected');
|
||
document.getElementById('customIndustryRow').classList.add('hidden');
|
||
|
||
const industry = btn.dataset.industry;
|
||
quizState.industry = industry;
|
||
|
||
setTimeout(() => {
|
||
goToStep(2);
|
||
loadSubNiches(industry);
|
||
}, 300);
|
||
}
|
||
|
||
function selectIndustryOther(btn) {
|
||
document.querySelectorAll('.industry-card').forEach(c => c.classList.remove('selected'));
|
||
btn.classList.add('selected');
|
||
document.getElementById('customIndustryRow').classList.remove('hidden');
|
||
document.getElementById('customIndustryInput').focus();
|
||
}
|
||
|
||
function confirmCustomIndustry() {
|
||
const val = document.getElementById('customIndustryInput').value.trim();
|
||
if (!val) { showToast('Please enter your industry', 'error'); return; }
|
||
quizState.industry = val;
|
||
goToStep(2);
|
||
loadSubNiches(val);
|
||
}
|
||
|
||
async function loadSubNiches(industry) {
|
||
const grid = document.getElementById('subNicheGrid');
|
||
const loading = document.getElementById('nicheLoading2');
|
||
document.getElementById('selectedIndustryLabel').textContent = industry;
|
||
|
||
grid.innerHTML = '';
|
||
loading.classList.remove('hidden');
|
||
setStatus('Finding sub-niches...', 'loading');
|
||
|
||
try {
|
||
const res = await fetch('/api/suggest-niches', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ industry })
|
||
});
|
||
const data = await res.json();
|
||
loading.classList.add('hidden');
|
||
setStatus('Ready', 'ready');
|
||
|
||
if (data.error) { showToast(data.error, 'error'); return; }
|
||
|
||
data.niches.forEach(niche => {
|
||
const card = document.createElement('button');
|
||
card.className = 'niche-card';
|
||
card.textContent = niche;
|
||
card.onclick = () => selectSubNiche(niche, card);
|
||
grid.appendChild(card);
|
||
});
|
||
} catch (e) {
|
||
loading.classList.add('hidden');
|
||
setStatus('Ready', 'ready');
|
||
showToast('Failed to load sub-niches', 'error');
|
||
}
|
||
}
|
||
|
||
function selectSubNiche(niche, card) {
|
||
document.querySelectorAll('#subNicheGrid .niche-card').forEach(c => c.classList.remove('selected'));
|
||
if (card) card.classList.add('selected');
|
||
quizState.subNiche = niche;
|
||
|
||
setTimeout(() => {
|
||
goToStep(3);
|
||
loadMicroNiches(quizState.industry, niche);
|
||
}, 300);
|
||
}
|
||
|
||
function confirmCustomSubNiche() {
|
||
const val = document.getElementById('customSubNicheInput').value.trim();
|
||
if (!val) { showToast('Please enter your sub-niche', 'error'); return; }
|
||
selectSubNiche(val, null);
|
||
}
|
||
|
||
async function loadMicroNiches(industry, subNiche) {
|
||
const grid = document.getElementById('microNicheGrid');
|
||
const loading = document.getElementById('nicheLoading3');
|
||
document.getElementById('breadcrumb1').textContent = industry;
|
||
document.getElementById('breadcrumb2').textContent = subNiche;
|
||
|
||
grid.innerHTML = '';
|
||
loading.classList.remove('hidden');
|
||
setStatus('Finding micro-niches...', 'loading');
|
||
|
||
try {
|
||
const res = await fetch('/api/suggest-niches', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ industry, sub_niche: subNiche })
|
||
});
|
||
const data = await res.json();
|
||
loading.classList.add('hidden');
|
||
setStatus('Ready', 'ready');
|
||
|
||
if (data.error) { showToast(data.error, 'error'); return; }
|
||
|
||
data.niches.forEach(niche => {
|
||
const card = document.createElement('button');
|
||
card.className = 'niche-card';
|
||
card.textContent = niche;
|
||
card.onclick = () => selectMicroNiche(niche, card);
|
||
grid.appendChild(card);
|
||
});
|
||
} catch (e) {
|
||
loading.classList.add('hidden');
|
||
setStatus('Ready', 'ready');
|
||
showToast('Failed to load micro-niches', 'error');
|
||
}
|
||
}
|
||
|
||
function selectMicroNiche(niche, card) {
|
||
document.querySelectorAll('#microNicheGrid .niche-card').forEach(c => c.classList.remove('selected'));
|
||
if (card) card.classList.add('selected');
|
||
quizState.microNiche = niche;
|
||
|
||
setTimeout(() => showQuizComplete(), 300);
|
||
}
|
||
|
||
function confirmCustomMicroNiche() {
|
||
const val = document.getElementById('customMicroNicheInput').value.trim();
|
||
if (!val) { showToast('Please enter your micro-niche', 'error'); return; }
|
||
quizState.microNiche = val;
|
||
showQuizComplete();
|
||
}
|
||
|
||
function showQuizComplete() {
|
||
quizState.step = 4;
|
||
document.getElementById('quizStep1').classList.add('hidden');
|
||
document.getElementById('quizStep2').classList.add('hidden');
|
||
document.getElementById('quizStep3').classList.add('hidden');
|
||
document.getElementById('quizComplete').classList.remove('hidden');
|
||
|
||
document.getElementById('finalIndustry').textContent = quizState.industry;
|
||
document.getElementById('finalSubNiche').textContent = quizState.subNiche;
|
||
document.getElementById('finalMicroNiche').textContent = quizState.microNiche;
|
||
|
||
// Update step indicator to all completed
|
||
for (let i = 1; i <= 3; i++) {
|
||
document.getElementById(`stepDot${i}`).classList.remove('active');
|
||
document.getElementById(`stepDot${i}`).classList.add('completed');
|
||
}
|
||
document.getElementById('stepLineFill').style.width = '100%';
|
||
|
||
showToast('Niche locked in!', 'success');
|
||
}
|
||
|
||
function skipToManual() {
|
||
document.getElementById('quizSection').classList.add('hidden');
|
||
document.getElementById('manualBuilder').classList.remove('hidden');
|
||
|
||
// Pre-fill with quiz data if available
|
||
const nameInput = document.querySelector('#campaignSection [data-field="campaign_name"]');
|
||
const adsetInput = document.querySelector('#adsetSection [data-field="adset_name"]');
|
||
const bodyInput = document.querySelector('.ad-card [data-field="body"]');
|
||
const titleInput = document.querySelector('.ad-card [data-field="title"]');
|
||
|
||
if (quizState.microNiche) {
|
||
if (nameInput) nameInput.value = quizState.microNiche + ' Campaign';
|
||
if (adsetInput) adsetInput.value = quizState.microNiche + ' - Core Audience';
|
||
if (titleInput) titleInput.value = quizState.microNiche;
|
||
updatePreview();
|
||
}
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
// PARALLEL CAMPAIGNS
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
|
||
async function generateParallelCampaigns() {
|
||
document.getElementById('quizSection').classList.add('hidden');
|
||
document.getElementById('campaignsSection').classList.remove('hidden');
|
||
document.getElementById('generationLoading').classList.remove('hidden');
|
||
document.getElementById('campaignCardsGrid').innerHTML = '';
|
||
document.getElementById('campaignsNicheLabel').textContent = quizState.microNiche;
|
||
|
||
setStatus('Generating campaigns...', 'loading');
|
||
|
||
try {
|
||
const res = await fetch('/api/generate-parallel-campaigns', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
industry: quizState.industry,
|
||
sub_niche: quizState.subNiche,
|
||
micro_niche: quizState.microNiche
|
||
})
|
||
});
|
||
const data = await res.json();
|
||
document.getElementById('generationLoading').classList.add('hidden');
|
||
|
||
if (data.error) {
|
||
showToast('Campaign generation failed: ' + data.error, 'error');
|
||
setStatus('Ready', 'ready');
|
||
return;
|
||
}
|
||
|
||
parallelCampaigns = data.campaigns;
|
||
renderCampaignCards();
|
||
setStatus('Ready', 'ready');
|
||
showToast(`Generated ${parallelCampaigns.length} campaigns!`, 'success');
|
||
|
||
} catch (e) {
|
||
document.getElementById('generationLoading').classList.add('hidden');
|
||
setStatus('Ready', 'ready');
|
||
showToast('Failed to generate campaigns', 'error');
|
||
}
|
||
}
|
||
|
||
const CTA_MAP = {
|
||
'LEARN_MORE': 'Learn More', 'SHOP_NOW': 'Shop Now', 'SIGN_UP': 'Sign Up',
|
||
'DOWNLOAD': 'Download', 'GET_QUOTE': 'Get Quote', 'CONTACT_US': 'Contact Us',
|
||
'BOOK_NOW': 'Book Now', 'APPLY_NOW': 'Apply Now', 'NO_BUTTON': ''
|
||
};
|
||
|
||
function renderCampaignCards() {
|
||
const grid = document.getElementById('campaignCardsGrid');
|
||
grid.innerHTML = '';
|
||
|
||
parallelCampaigns.forEach((c, i) => {
|
||
const card = document.createElement('div');
|
||
card.className = `campaign-card ${c.selected ? '' : 'deselected'}`;
|
||
card.id = `campaignCard${i}`;
|
||
|
||
const targeting = c.targeting || {};
|
||
const adCopy = c.ad_copy || {};
|
||
const hasImage = !!c.image_url;
|
||
|
||
card.innerHTML = `
|
||
<div class="campaign-card-header">
|
||
<div>
|
||
<span class="campaign-card-number">#${i + 1}</span>
|
||
<span class="campaign-card-title">${escapeHtml(c.campaign_name || '')}</span>
|
||
</div>
|
||
<button class="campaign-toggle ${c.selected ? 'active' : ''}" onclick="toggleCampaign(${i})" title="Toggle campaign"></button>
|
||
</div>
|
||
<div class="campaign-card-body">
|
||
<div class="campaign-micro-niche">${escapeHtml(c.micro_niche || '')}</div>
|
||
<div class="campaign-image-area" id="campaignImage${i}">
|
||
${hasImage
|
||
? `<img src="${c.image_url}" alt="Ad image">`
|
||
: `<div class="campaign-image-placeholder">
|
||
<svg class="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"/></svg>
|
||
<span>No image yet</span>
|
||
</div>`
|
||
}
|
||
</div>
|
||
<div class="campaign-ad-copy">${escapeHtml(adCopy.body || '')}</div>
|
||
<div class="campaign-targeting">
|
||
<strong>🎯 Targeting:</strong> Ages ${targeting.age_min || '?'}-${targeting.age_max || '?'} · ${targeting.gender || 'All'}<br>
|
||
<strong>Interests:</strong> ${escapeHtml(targeting.interests || 'N/A')}<br>
|
||
<strong>Behaviors:</strong> ${escapeHtml(targeting.behaviors || 'N/A')}
|
||
</div>
|
||
<div class="campaign-landing">📄 ${escapeHtml(c.landing_page_angle || '')}</div>
|
||
<div class="campaign-card-actions">
|
||
<button class="btn-generate" onclick="generateCampaignImage(${i})" ${hasImage ? 'style="opacity:0.6"' : ''}>
|
||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"/></svg>
|
||
${hasImage ? 'Regenerate' : 'Generate'} Image
|
||
</button>
|
||
</div>
|
||
</div>
|
||
`;
|
||
grid.appendChild(card);
|
||
});
|
||
}
|
||
|
||
function toggleCampaign(index) {
|
||
parallelCampaigns[index].selected = !parallelCampaigns[index].selected;
|
||
const card = document.getElementById(`campaignCard${index}`);
|
||
const toggle = card.querySelector('.campaign-toggle');
|
||
|
||
if (parallelCampaigns[index].selected) {
|
||
card.classList.remove('deselected');
|
||
toggle.classList.add('active');
|
||
} else {
|
||
card.classList.add('deselected');
|
||
toggle.classList.remove('active');
|
||
}
|
||
|
||
// Also update gallery view if visible
|
||
const galleryCard = document.getElementById(`galleryCard${index}`);
|
||
if (galleryCard) {
|
||
if (parallelCampaigns[index].selected) {
|
||
galleryCard.classList.remove('deselected');
|
||
} else {
|
||
galleryCard.classList.add('deselected');
|
||
}
|
||
}
|
||
}
|
||
|
||
async function generateCampaignImage(index) {
|
||
const campaign = parallelCampaigns[index];
|
||
const imageArea = document.getElementById(`campaignImage${index}`);
|
||
const card = document.getElementById(`campaignCard${index}`);
|
||
const btn = card.querySelector('.btn-generate');
|
||
|
||
if (!campaign.image_prompt) {
|
||
showToast('No image prompt for this campaign', 'error');
|
||
return;
|
||
}
|
||
|
||
btn.disabled = true;
|
||
btn.classList.add('generating');
|
||
imageArea.innerHTML = `<div class="campaign-image-placeholder"><div class="ai-spinner"></div><span>Generating...</span></div>`;
|
||
setStatus(`Generating image ${index + 1}...`, 'loading');
|
||
|
||
try {
|
||
const res = await fetch('/api/generate-image', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ prompt: campaign.image_prompt })
|
||
});
|
||
const data = await res.json();
|
||
|
||
if (data.error) throw new Error(data.error);
|
||
|
||
campaign.image_url = data.url;
|
||
imageArea.innerHTML = `<img src="${data.url}" alt="Ad image">`;
|
||
|
||
// Update gallery view too
|
||
const galleryImg = document.querySelector(`#galleryCard${index} .gallery-fb-image`);
|
||
if (galleryImg) {
|
||
galleryImg.innerHTML = `<img src="${data.url}" alt="Ad image">`;
|
||
}
|
||
|
||
setStatus('Ready', 'ready');
|
||
showToast(`Image generated for campaign #${index + 1}`, 'success');
|
||
|
||
} catch (e) {
|
||
imageArea.innerHTML = `<div class="campaign-image-placeholder"><span style="color:var(--red)">Failed</span></div>`;
|
||
setStatus('Ready', 'ready');
|
||
showToast(`Image failed: ${e.message}`, 'error');
|
||
}
|
||
|
||
btn.disabled = false;
|
||
btn.classList.remove('generating');
|
||
}
|
||
|
||
async function generateAllImages() {
|
||
const selected = parallelCampaigns.map((c, i) => ({ campaign: c, index: i })).filter(x => x.campaign.selected);
|
||
|
||
if (selected.length === 0) {
|
||
showToast('No campaigns selected', 'error');
|
||
return;
|
||
}
|
||
|
||
const progressBar = document.getElementById('imageProgress');
|
||
const fill = document.getElementById('progressBarFill');
|
||
const text = document.getElementById('progressText');
|
||
const btn = document.getElementById('generateAllBtn');
|
||
|
||
progressBar.classList.remove('hidden');
|
||
btn.disabled = true;
|
||
let completed = 0;
|
||
|
||
text.textContent = `Generating images: 0/${selected.length}`;
|
||
fill.style.width = '0%';
|
||
|
||
for (const item of selected) {
|
||
if (item.campaign.image_url) {
|
||
completed++;
|
||
text.textContent = `Generating images: ${completed}/${selected.length}`;
|
||
fill.style.width = (completed / selected.length * 100) + '%';
|
||
continue;
|
||
}
|
||
|
||
await generateCampaignImage(item.index);
|
||
completed++;
|
||
text.textContent = `Generating images: ${completed}/${selected.length}`;
|
||
fill.style.width = (completed / selected.length * 100) + '%';
|
||
}
|
||
|
||
btn.disabled = false;
|
||
text.textContent = `All ${completed} images generated!`;
|
||
showToast('All images generated!', 'success');
|
||
|
||
setTimeout(() => progressBar.classList.add('hidden'), 3000);
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
// GALLERY VIEW
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
|
||
function toggleGalleryView() {
|
||
galleryMode = !galleryMode;
|
||
const grid = document.getElementById('campaignCardsGrid');
|
||
const gallery = document.getElementById('galleryView');
|
||
const label = document.getElementById('galleryToggleLabel');
|
||
|
||
if (galleryMode) {
|
||
grid.classList.add('hidden');
|
||
gallery.classList.remove('hidden');
|
||
label.textContent = 'Card View';
|
||
renderGallery();
|
||
} else {
|
||
grid.classList.remove('hidden');
|
||
gallery.classList.add('hidden');
|
||
label.textContent = 'Gallery View';
|
||
}
|
||
}
|
||
|
||
function renderGallery() {
|
||
const gallery = document.getElementById('galleryView');
|
||
gallery.innerHTML = '';
|
||
|
||
parallelCampaigns.forEach((c, i) => {
|
||
const adCopy = c.ad_copy || {};
|
||
const ctaText = CTA_MAP[c.cta] || 'Learn More';
|
||
|
||
const card = document.createElement('div');
|
||
card.className = `gallery-ad-card ${c.selected ? '' : 'deselected'}`;
|
||
card.id = `galleryCard${i}`;
|
||
|
||
card.innerHTML = `
|
||
<div class="gallery-phone-frame">
|
||
<div class="gallery-fb-post">
|
||
<div class="gallery-fb-header">
|
||
<div class="gallery-fb-avatar"></div>
|
||
<div>
|
||
<div class="gallery-fb-page">${escapeHtml(c.campaign_name || 'Campaign')}</div>
|
||
<div class="gallery-fb-sponsored">Sponsored · 🌐</div>
|
||
</div>
|
||
</div>
|
||
<div class="gallery-fb-body" contenteditable="true" data-campaign="${i}" data-field="body" onblur="updateCampaignField(${i}, 'body', this.textContent)">${escapeHtml(adCopy.body || '')}</div>
|
||
<div class="gallery-fb-image">
|
||
${c.image_url
|
||
? `<img src="${c.image_url}" alt="Ad">`
|
||
: `<div class="campaign-image-placeholder" style="height:100%;display:flex;align-items:center;justify-content:center"><span>No image</span></div>`
|
||
}
|
||
</div>
|
||
<div class="gallery-fb-link-bar">
|
||
<div>
|
||
<div class="gallery-fb-headline" contenteditable="true" onblur="updateCampaignField(${i}, 'headline', this.textContent)">${escapeHtml(adCopy.headline || '')}</div>
|
||
<div class="gallery-fb-description" contenteditable="true" onblur="updateCampaignField(${i}, 'description', this.textContent)">${escapeHtml(adCopy.description || '')}</div>
|
||
</div>
|
||
<button class="gallery-fb-cta">${ctaText}</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="gallery-card-controls">
|
||
<span class="gallery-niche-label">${escapeHtml(c.micro_niche || '')}</span>
|
||
<button class="campaign-toggle ${c.selected ? 'active' : ''}" onclick="toggleCampaign(${i})"></button>
|
||
</div>
|
||
`;
|
||
gallery.appendChild(card);
|
||
});
|
||
}
|
||
|
||
function updateCampaignField(index, field, value) {
|
||
if (!parallelCampaigns[index].ad_copy) parallelCampaigns[index].ad_copy = {};
|
||
parallelCampaigns[index].ad_copy[field] = value;
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
// CSV EXPORT (for parallel campaigns)
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
|
||
async function exportSelectedCampaigns() {
|
||
// Check if we have parallel campaigns
|
||
if (parallelCampaigns.length > 0) {
|
||
exportParallelCampaignsCSV();
|
||
return;
|
||
}
|
||
|
||
// Fallback: original manual flow
|
||
generateCSV();
|
||
}
|
||
|
||
async function exportParallelCampaignsCSV() {
|
||
const selected = parallelCampaigns.filter(c => c.selected);
|
||
|
||
if (selected.length === 0) {
|
||
showToast('No campaigns selected for export', 'error');
|
||
return;
|
||
}
|
||
|
||
// Build CSV rows
|
||
const ads = selected.map(c => {
|
||
const targeting = c.targeting || {};
|
||
const adCopy = c.ad_copy || {};
|
||
return {
|
||
campaign_name: c.campaign_name || '',
|
||
campaign_objective: 'OUTCOME_LEADS',
|
||
buying_type: 'AUCTION',
|
||
campaign_status: 'PAUSED',
|
||
campaign_budget_optimization: 'FALSE',
|
||
adset_name: c.adset_name || '',
|
||
adset_status: 'PAUSED',
|
||
targeting_age_min: String(targeting.age_min || 18),
|
||
targeting_age_max: String(targeting.age_max || 65),
|
||
targeting_genders: targeting.gender || 'All',
|
||
targeting_interests: targeting.interests || '',
|
||
targeting_behaviors: targeting.behaviors || '',
|
||
targeting_geo_locations: 'US',
|
||
ad_name: (c.campaign_name || 'Ad') + ' - Creative 1',
|
||
ad_status: 'PAUSED',
|
||
body: adCopy.body || '',
|
||
title: adCopy.headline || '',
|
||
caption: adCopy.description || '',
|
||
call_to_action: c.cta || 'LEARN_MORE',
|
||
image_file: c.image_url || ''
|
||
};
|
||
});
|
||
|
||
setStatus('Generating CSV...', 'loading');
|
||
|
||
try {
|
||
const res = await fetch('/api/generate-csv', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ ads })
|
||
});
|
||
const data = await res.json();
|
||
|
||
if (data.error) throw new Error(data.error);
|
||
|
||
lastExportCSVData = data;
|
||
|
||
// Show CSV preview section
|
||
const section = document.getElementById('csvPreviewSection');
|
||
section.classList.remove('hidden');
|
||
|
||
document.getElementById('csvExportRowCount').textContent = `${data.rows.length} campaign(s) · ${data.columns.length} columns`;
|
||
|
||
// Build table
|
||
const activeCols = data.columns.filter(col =>
|
||
data.rows.some(row => row[col] && row[col].trim())
|
||
);
|
||
|
||
let html = '<table><thead><tr>';
|
||
activeCols.forEach(col => { html += `<th>${escapeHtml(col)}</th>`; });
|
||
html += '</tr></thead><tbody>';
|
||
data.rows.forEach(row => {
|
||
html += '<tr>';
|
||
activeCols.forEach(col => {
|
||
html += `<td title="${escapeHtml(row[col] || '')}">${escapeHtml(row[col] || '—')}</td>`;
|
||
});
|
||
html += '</tr>';
|
||
});
|
||
html += '</tbody></table>';
|
||
document.getElementById('csvExportTableWrap').innerHTML = html;
|
||
|
||
section.scrollIntoView({ behavior: 'smooth' });
|
||
setStatus('Ready', 'ready');
|
||
showToast(`CSV generated — ${data.rows.length} row(s)`, 'success');
|
||
|
||
} catch (e) {
|
||
setStatus('Ready', 'ready');
|
||
showToast('CSV generation failed: ' + e.message, 'error');
|
||
}
|
||
}
|
||
|
||
function downloadExportCSV() {
|
||
if (!lastExportCSVData) return;
|
||
const blob = new Blob([lastExportCSVData.csv_content], { type: 'text/csv' });
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement('a');
|
||
a.href = url;
|
||
a.download = lastExportCSVData.filename;
|
||
document.body.appendChild(a);
|
||
a.click();
|
||
document.body.removeChild(a);
|
||
URL.revokeObjectURL(url);
|
||
showToast(`Downloaded ${lastExportCSVData.filename}`, 'success');
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
// ORIGINAL MANUAL BUILDER FUNCTIONS
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
|
||
function toggleSection(id) {
|
||
const body = document.getElementById(`body-${id}`);
|
||
const chevron = document.getElementById(`chevron-${id}`);
|
||
if (body) body.classList.toggle('collapsed');
|
||
if (chevron) chevron.classList.toggle('rotated');
|
||
}
|
||
|
||
function toggleSubSection(id) {
|
||
const body = document.getElementById(`body-${id}`);
|
||
const chevron = document.getElementById(`chevron-${id}`);
|
||
if (body) body.classList.toggle('collapsed');
|
||
if (chevron) chevron.classList.toggle('rotated');
|
||
}
|
||
|
||
function toggleCBO(value) {
|
||
const group = document.getElementById('cboBudgetGroup');
|
||
if (group) group.style.display = value === 'TRUE' ? '' : 'none';
|
||
}
|
||
|
||
function switchTab(tab) {
|
||
document.querySelectorAll('.preview-tab').forEach(t => t.classList.remove('active'));
|
||
document.querySelector(`[data-tab="${tab}"]`).classList.add('active');
|
||
|
||
const previewPanel = document.getElementById('previewPanel');
|
||
const csvPanel = document.getElementById('csvPanel');
|
||
|
||
if (tab === 'preview') {
|
||
previewPanel.classList.remove('hidden');
|
||
csvPanel.classList.add('hidden');
|
||
} else {
|
||
previewPanel.classList.add('hidden');
|
||
csvPanel.classList.remove('hidden');
|
||
}
|
||
}
|
||
|
||
function updateCharCount(textarea) {
|
||
const counter = textarea.parentElement.querySelector('.char-count');
|
||
if (counter) {
|
||
const len = textarea.value.length;
|
||
const max = textarea.maxLength || 2200;
|
||
counter.textContent = `${len.toLocaleString()} / ${max.toLocaleString()}`;
|
||
counter.style.color = len > max * 0.9 ? '#ef4444' : '';
|
||
}
|
||
}
|
||
|
||
function updatePreview() {
|
||
const adCard = document.querySelectorAll('.ad-card')[previewAdIndex];
|
||
if (!adCard) return;
|
||
|
||
const body = getFieldValue(adCard, 'body') || 'Your ad text will appear here.';
|
||
document.getElementById('previewBody').textContent = body;
|
||
|
||
const title = getFieldValue(adCard, 'title') || 'Your Headline Here';
|
||
document.getElementById('previewHeadline').textContent = title;
|
||
|
||
const caption = getFieldValue(adCard, 'caption') || 'Your description text';
|
||
document.getElementById('previewDescription').textContent = caption;
|
||
|
||
const link = getFieldValue(adCard, 'link') || getFieldValue(adCard, 'display_link') || '';
|
||
let displayDomain = 'example.com';
|
||
if (link) { try { displayDomain = new URL(link).hostname; } catch { displayDomain = link.replace(/https?:\/\//, '').split('/')[0] || 'example.com'; } }
|
||
document.getElementById('previewDisplayLink').textContent = getFieldValue(adCard, 'display_link') || displayDomain;
|
||
|
||
const cta = getFieldValue(adCard, 'call_to_action') || 'LEARN_MORE';
|
||
const ctaBtn = document.getElementById('previewCTA');
|
||
const ctaText = CTA_MAP[cta] || 'Learn More';
|
||
ctaBtn.textContent = ctaText;
|
||
ctaBtn.style.display = ctaText ? '' : 'none';
|
||
|
||
const campaignName = document.querySelector('[data-field="campaign_name"]');
|
||
if (campaignName && campaignName.value) {
|
||
document.getElementById('previewPageName').textContent = campaignName.value;
|
||
}
|
||
}
|
||
|
||
function getFieldValue(container, fieldName) {
|
||
const el = container.querySelector(`[data-field="${fieldName}"]`);
|
||
return el ? el.value : '';
|
||
}
|
||
|
||
function addAd() {
|
||
const container = document.getElementById('adsContainer');
|
||
const index = adCount++;
|
||
const num = document.querySelectorAll('.ad-card').length + 1;
|
||
const section = document.createElement('section');
|
||
section.className = 'form-card ad-card';
|
||
section.dataset.adIndex = index;
|
||
|
||
section.innerHTML = `
|
||
<div class="card-header" onclick="toggleSection('ad-${index}')">
|
||
<div class="flex items-center gap-2">
|
||
<div class="section-icon ad-icon"><svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"/></svg></div>
|
||
<h2 class="card-title">Ad Creative <span class="ad-number">#${num}</span></h2>
|
||
</div>
|
||
<div class="flex items-center gap-2">
|
||
<button class="btn-delete-ad" onclick="event.stopPropagation(); removeAd(this);">✕ Remove</button>
|
||
<svg class="chevron w-5 h-5 text-gray-400" id="chevron-ad-${index}" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/></svg>
|
||
</div>
|
||
</div>
|
||
<div class="card-body" id="body-ad-${index}">
|
||
<div class="field-grid">
|
||
<div class="field-group col-span-2"><label class="field-label">Ad Name <span class="req">*</span></label><input type="text" class="field-input" data-field="ad_name" placeholder="e.g. Ad ${num}" oninput="updatePreview()"></div>
|
||
<div class="field-group"><label class="field-label">Status</label><select class="field-input" data-field="ad_status"><option value="PAUSED">Paused</option><option value="ACTIVE">Active</option></select></div>
|
||
<div class="field-group"><label class="field-label">Call to Action</label><select class="field-input" data-field="call_to_action" onchange="updatePreview()"><option value="">— None —</option><option value="LEARN_MORE">Learn More</option><option value="SHOP_NOW">Shop Now</option><option value="SIGN_UP">Sign Up</option><option value="DOWNLOAD">Download</option><option value="GET_QUOTE">Get Quote</option><option value="CONTACT_US">Contact Us</option><option value="BOOK_NOW">Book Now</option><option value="APPLY_NOW">Apply Now</option></select></div>
|
||
<div class="field-group col-span-2"><label class="field-label">Body Text</label><textarea class="field-input field-textarea" data-field="body" placeholder="Primary text..." rows="3" maxlength="2200" oninput="updatePreview(); updateCharCount(this)"></textarea><span class="char-count">0 / 2,200</span></div>
|
||
<div class="field-group"><label class="field-label">Headline</label><input type="text" class="field-input" data-field="title" placeholder="Headline" maxlength="255" oninput="updatePreview()"></div>
|
||
<div class="field-group"><label class="field-label">Description</label><input type="text" class="field-input" data-field="caption" placeholder="Description" maxlength="255" oninput="updatePreview()"></div>
|
||
<div class="field-group col-span-2"><label class="field-label">Link URL</label><input type="url" class="field-input" data-field="link" placeholder="https://..." oninput="updatePreview()"></div>
|
||
</div>
|
||
<div class="ai-gen-section">
|
||
<div class="ai-gen-header"><span class="text-sm font-semibold text-amber-400">✨ AI Image Generation</span></div>
|
||
<div class="ai-gen-body">
|
||
<div class="flex gap-2">
|
||
<input type="text" class="field-input flex-1 ai-prompt-input" data-ai-prompt placeholder="Describe the ad image...">
|
||
<button class="btn-generate" onclick="generateImage(this)"><svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"/></svg>Generate</button>
|
||
</div>
|
||
<div class="generated-images" data-generated-images></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
container.appendChild(section);
|
||
section.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||
showToast('Ad creative added', 'success');
|
||
}
|
||
|
||
function removeAd(btn) {
|
||
const card = btn.closest('.ad-card');
|
||
if (document.querySelectorAll('.ad-card').length <= 1) {
|
||
showToast('You need at least one ad', 'error');
|
||
return;
|
||
}
|
||
card.remove();
|
||
document.querySelectorAll('.ad-card .ad-number').forEach((num, i) => num.textContent = `#${i + 1}`);
|
||
previewAdIndex = Math.min(previewAdIndex, document.querySelectorAll('.ad-card').length - 1);
|
||
updatePreview();
|
||
showToast('Ad removed', 'info');
|
||
}
|
||
|
||
// ── AI Image Generation (Manual) ────────────────────────────────────────────
|
||
|
||
async function generateImage(btn) {
|
||
const adCard = btn.closest('.ad-card') || btn.closest('.form-card');
|
||
const promptInput = adCard.querySelector('[data-ai-prompt]');
|
||
const container = adCard.querySelector('[data-generated-images]');
|
||
const prompt = promptInput.value.trim();
|
||
|
||
if (!prompt) { showToast('Please enter an image prompt', 'error'); promptInput.focus(); return; }
|
||
|
||
btn.disabled = true;
|
||
btn.classList.add('generating');
|
||
const origText = btn.innerHTML;
|
||
btn.innerHTML = `<svg class="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/></svg> Generating...`;
|
||
|
||
const placeholder = document.createElement('div');
|
||
placeholder.className = 'generating-placeholder';
|
||
placeholder.innerHTML = '<svg class="w-5 h-5 text-gray-500 animate-spin" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/></svg>';
|
||
container.appendChild(placeholder);
|
||
|
||
setStatus('Generating image...', 'loading');
|
||
|
||
try {
|
||
const res = await fetch('/api/generate-image', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ prompt })
|
||
});
|
||
const data = await res.json();
|
||
if (data.error) throw new Error(data.error);
|
||
|
||
placeholder.remove();
|
||
const thumb = document.createElement('div');
|
||
thumb.className = 'generated-image-thumb';
|
||
thumb.innerHTML = `<img src="${data.url}" alt="${prompt}" loading="lazy">`;
|
||
thumb.onclick = () => selectImage(thumb, data.url, adCard);
|
||
container.appendChild(thumb);
|
||
|
||
if (container.querySelectorAll('.generated-image-thumb').length === 1) {
|
||
selectImage(thumb, data.url, adCard);
|
||
}
|
||
|
||
setStatus('Ready', 'ready');
|
||
showToast('Image generated successfully', 'success');
|
||
} catch (err) {
|
||
placeholder.remove();
|
||
setStatus('Ready', 'ready');
|
||
showToast(`Image generation failed: ${err.message}`, 'error');
|
||
}
|
||
|
||
btn.disabled = false;
|
||
btn.classList.remove('generating');
|
||
btn.innerHTML = origText;
|
||
}
|
||
|
||
function selectImage(thumb, url, adCard) {
|
||
thumb.parentElement.querySelectorAll('.generated-image-thumb').forEach(t => t.classList.remove('selected'));
|
||
thumb.classList.add('selected');
|
||
document.getElementById('previewImage').innerHTML = `<img src="${url}" alt="Ad Image">`;
|
||
const imageFileInput = adCard.querySelector('[data-field="image_file"]');
|
||
if (imageFileInput) imageFileInput.value = url;
|
||
}
|
||
|
||
// ── CSV Generation (Manual) ─────────────────────────────────────────────────
|
||
|
||
async function generateCSV() {
|
||
const ads = collectFormData();
|
||
if (ads.length === 0) { showToast('No ad data to export', 'error'); return; }
|
||
|
||
for (let i = 0; i < ads.length; i++) {
|
||
if (!ads[i].campaign_name) { showToast('Campaign Name is required', 'error'); return; }
|
||
if (!ads[i].adset_name) { showToast('Ad Set Name is required', 'error'); return; }
|
||
if (!ads[i].ad_name) { showToast(`Ad #${i + 1}: Ad Name is required`, 'error'); return; }
|
||
}
|
||
|
||
setStatus('Generating CSV...', 'loading');
|
||
|
||
try {
|
||
const res = await fetch('/api/generate-csv', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ ads })
|
||
});
|
||
const data = await res.json();
|
||
if (data.error) throw new Error(data.error);
|
||
|
||
lastCSVData = data;
|
||
renderCSVPreview(data);
|
||
switchTab('csv');
|
||
setStatus('Ready', 'ready');
|
||
showToast(`CSV generated — ${data.rows.length} row(s)`, 'success');
|
||
} catch (err) {
|
||
setStatus('Ready', 'ready');
|
||
showToast(`CSV generation failed: ${err.message}`, 'error');
|
||
}
|
||
}
|
||
|
||
function collectFormData() {
|
||
const ads = [];
|
||
const adCards = document.querySelectorAll('.ad-card');
|
||
const sharedData = {};
|
||
const campaignSection = document.getElementById('campaignSection');
|
||
const adsetSection = document.getElementById('adsetSection');
|
||
|
||
if (campaignSection) campaignSection.querySelectorAll('[data-field]').forEach(el => { sharedData[el.dataset.field] = el.value; });
|
||
if (adsetSection) adsetSection.querySelectorAll('[data-field]').forEach(el => { sharedData[el.dataset.field] = el.value; });
|
||
|
||
adCards.forEach(card => {
|
||
const adData = { ...sharedData };
|
||
card.querySelectorAll('[data-field]').forEach(el => { adData[el.dataset.field] = el.value; });
|
||
ads.push(adData);
|
||
});
|
||
return ads;
|
||
}
|
||
|
||
function renderCSVPreview(data) {
|
||
document.getElementById('csvEmpty').classList.add('hidden');
|
||
document.getElementById('csvContent').classList.remove('hidden');
|
||
document.getElementById('csvRowCount').textContent = `${data.rows.length} row(s) · ${data.columns.length} columns`;
|
||
|
||
const activeCols = data.columns.filter(col => data.rows.some(row => row[col] && row[col].trim()));
|
||
let html = '<table><thead><tr>';
|
||
activeCols.forEach(col => { html += `<th>${escapeHtml(col)}</th>`; });
|
||
html += '</tr></thead><tbody>';
|
||
data.rows.forEach(row => {
|
||
html += '<tr>';
|
||
activeCols.forEach(col => { html += `<td title="${escapeHtml(row[col] || '')}">${escapeHtml(row[col] || '—')}</td>`; });
|
||
html += '</tr>';
|
||
});
|
||
html += '</tbody></table>';
|
||
document.getElementById('csvTableWrap').innerHTML = html;
|
||
}
|
||
|
||
function downloadCSV() {
|
||
if (!lastCSVData) return;
|
||
const blob = new Blob([lastCSVData.csv_content], { type: 'text/csv' });
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement('a');
|
||
a.href = url; a.download = lastCSVData.filename;
|
||
document.body.appendChild(a); a.click(); document.body.removeChild(a);
|
||
URL.revokeObjectURL(url);
|
||
showToast(`Downloaded ${lastCSVData.filename}`, 'success');
|
||
}
|
||
|
||
// ── Status & Toast ──────────────────────────────────────────────────────────
|
||
|
||
function setStatus(text, type) {
|
||
const badge = document.getElementById('statusBadge');
|
||
if (!badge) return;
|
||
badge.textContent = text;
|
||
badge.className = 'status-badge';
|
||
if (type === 'loading') badge.classList.add('loading');
|
||
if (type === 'error') badge.classList.add('error');
|
||
}
|
||
|
||
function showToast(message, type = 'info') {
|
||
const container = document.getElementById('toastContainer');
|
||
const toast = document.createElement('div');
|
||
toast.className = `toast ${type}`;
|
||
const icons = { success: '✓', error: '✕', info: 'ℹ' };
|
||
toast.innerHTML = `<span>${icons[type] || 'ℹ'}</span><span>${escapeHtml(message)}</span>`;
|
||
container.appendChild(toast);
|
||
setTimeout(() => {
|
||
toast.style.animation = 'slideOut 0.3s ease forwards';
|
||
setTimeout(() => toast.remove(), 300);
|
||
}, 4000);
|
||
}
|
||
|
||
function escapeHtml(str) {
|
||
if (!str) return '';
|
||
const div = document.createElement('div');
|
||
div.textContent = str;
|
||
return div.innerHTML;
|
||
}
|
||
|
||
// ── CSS Animation Helper ────────────────────────────────────────────────────
|
||
|
||
const style = document.createElement('style');
|
||
style.textContent = `@keyframes spin { to { transform: rotate(360deg); } } .animate-spin { animation: spin 1s linear infinite; }`;
|
||
document.head.appendChild(style);
|
||
|
||
// ── Init ────────────────────────────────────────────────────────────────────
|
||
|
||
document.addEventListener('DOMContentLoaded', () => {
|
||
updateStepIndicator();
|
||
});
|