Client Details

Name:

Email:

Credits: 0

Shared Client Portal Link: Loading...

Assign Credits

Edit Client Details

Generated Pages

Services:
Towns:
Service Town Views Unique Visitors Indexing Status Created At Link

Local Keyword Rankings Tracker

Monitor organic search positions, maps ranking, and visibility shifts for this client's service areas.

Track New Keyword

Bulk Import via CSV

Upload a CSV containing: keyword, town, service headers to track keywords in bulk.

Fetching ranking reports...

Captured Leads

Leads captured from forms on this client's generated landing pages.

Name Email Phone Message Source Page Date

Embeddable Client Widgets

Generate and copy responsive widget code snippets to embed directly on your client's main website.

Widget Configurations

Live Preview

`; } else { embedCode = `\n
\n`; } embedCodeTextarea.value = embedCode; // Render live preview renderPreview(pages, theme, layout, colorInput.value); } } function renderPreview(pagesList, theme, layout, baseColor) { previewBox.innerHTML = ''; if (theme === 'light') { previewBox.style.background = '#ffffff'; previewBox.style.color = '#1f2937'; } else if (theme === 'dark') { previewBox.style.background = '#1f2937'; previewBox.style.color = '#f3f4f6'; } else { // glassmorphic previewBox.style.background = 'rgba(31, 41, 55, 0.4)'; previewBox.style.backdropFilter = 'blur(10px)'; previewBox.style.color = '#f3f4f6'; } if (pagesList.length === 0) { previewBox.innerHTML = '

Generate pages first to preview the widget.

'; return; } // Add base styles to preview doc if needed let style = document.getElementById('widget-client-preview-styles'); if (!style) { style = document.createElement('style'); style.id = 'widget-client-preview-styles'; document.head.appendChild(style); } style.textContent = ` .ll-preview-trigger-${clientId} { background: ${baseColor}; color: #ffffff; padding: 10px 18px; border-radius: 20px; cursor: pointer; font-weight: 700; font-size: 0.85rem; display: flex; align-items: center; gap: 6px; } .ll-preview-grid-${clientId} { display: grid; grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); gap: 8px; width: 100%; } .ll-preview-card-${clientId} { border-radius: 6px; padding: 10px; text-decoration: none; background: ${theme === 'light' ? '#ffffff' : 'rgba(255,255,255,0.05)'}; border: 1px solid ${theme === 'light' ? '#e5e7eb' : 'rgba(255,255,255,0.1)'}; color: inherit; } .ll-preview-card-town-${clientId} { font-weight: bold; font-size: 0.85rem; } .ll-preview-card-service-${clientId} { font-size: 0.75rem; opacity: 0.7; } .ll-preview-list-${clientId} { list-style: none; padding: 0; margin: 0; display: flex; flex-wrap: wrap; gap: 6px; } .ll-preview-list-${clientId} li a { display: inline-block; padding: 4px 10px; border-radius: 12px; font-size: 0.75rem; text-decoration: none; background: ${theme === 'light' ? '#f3f4f6' : 'rgba(255,255,255,0.05)'}; color: inherit; border: 1px solid ${theme === 'light' ? '#e5e7eb' : 'rgba(255,255,255,0.1)'}; } `; if (layout === 'badge') { previewBox.innerHTML = `
📍 Serving ${pagesList.length} Areas

Click badge to preview area listings modal.

`; const trigger = previewBox.querySelector('#preview-trigger-btn'); const modal = previewBox.querySelector('#preview-modal-box'); const closeBtn = previewBox.querySelector('#preview-close-modal-btn'); trigger.addEventListener('click', () => { modal.style.display = modal.style.display === 'flex' ? 'none' : 'flex'; }); closeBtn.addEventListener('click', (e) => { e.stopPropagation(); modal.style.display = 'none'; }); } else if (layout === 'grid') { let gridItems = pagesList.slice(0, 4).map(p => `
${p.town}
${p.service}
`).join(''); if (pagesList.length > 4) { gridItems += `
+ ${pagesList.length - 4} more
`; } previewBox.innerHTML = `
📍 Areas We Serve
${gridItems}
`; } else { let listItems = pagesList.slice(0, 5).map(p => `
  • ${p.service} in ${p.town}
  • `).join(''); if (pagesList.length > 5) { listItems += `
  • + ${pagesList.length - 5} more
  • `; } previewBox.innerHTML = `
    📍 Areas We Serve
    `; } } // Copy Code Button handler copyBtn.addEventListener('click', () => { embedCodeTextarea.select(); embedCodeTextarea.setSelectionRange(0, 99999); navigator.clipboard.writeText(embedCodeTextarea.value).then(() => { const originalText = copyBtn.innerHTML; copyBtn.innerHTML = ' Copied!'; setTimeout(() => { copyBtn.innerHTML = originalText; }, 2000); }).catch(err => { console.error('Failed to copy widget code:', err); alert('Failed to copy to clipboard.'); }); }); // Initial load of embed code and preview updateEmbedCodeAndPreview(); } // Bind Search & Filter Event Listeners const pageSearchInput = document.getElementById('page-search-input'); if (pageSearchInput) { pageSearchInput.addEventListener('input', (e) => { searchQuery = e.target.value; renderFilteredPages(); }); } const clearFiltersBtn = document.getElementById('clear-filters-btn'); if (clearFiltersBtn) { clearFiltersBtn.addEventListener('click', () => { if (pageSearchInput) { pageSearchInput.value = ''; } searchQuery = ''; activeServiceFilters.clear(); activeTownFilters.clear(); populateTagFilters(); renderFilteredPages(); }); } // Click listener for checking indexing status (via delegation on the table) generatedPages.addEventListener('click', async (event) => { const checkBtn = event.target.closest('.check-indexing-btn'); if (checkBtn) { const pageId = checkBtn.getAttribute('data-id'); const icon = checkBtn.querySelector('i'); if (icon.classList.contains('fa-spin')) return; // already loading icon.classList.add('fa-spin'); checkBtn.disabled = true; try { const response = await fetch('/api/check-indexing-status', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ pageId }) }); if (response.ok) { const resData = await response.json(); const page = clientPages.find(p => p.pageId === pageId); if (page) { page.indexingStatus = resData.indexingStatus; page.lastIndexingCheck = resData.lastIndexingCheck; } renderFilteredPages(); } else { const errData = await response.json(); alert(`Error checking indexing status: ${errData.message || 'Failed request.'}`); icon.classList.remove('fa-spin'); checkBtn.disabled = false; } } catch (error) { console.error('Error checking indexing status:', error); alert('An unexpected error occurred while checking indexing status.'); icon.classList.remove('fa-spin'); checkBtn.disabled = false; } } }); assignCreditsForm.addEventListener('submit', async (e) => { e.preventDefault(); assignCreditsMessage.textContent = ''; // Clear previous messages const credits = assignCreditsAmount.value; if (!currentClientId) { assignCreditsMessage.textContent = 'Error: Client ID not found.'; assignCreditsMessage.style.color = 'red'; return; } try { const response = await fetch('/api/assign-credits', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ clientId: currentClientId, credits: credits }), }); const data = await response.json(); if (response.ok) { assignCreditsMessage.textContent = data.message || 'Credits assigned successfully!'; assignCreditsMessage.style.color = 'green'; // Refresh client details to show updated credit balance fetchClientDetails(); } else { assignCreditsMessage.textContent = data.message || 'Failed to assign credits.'; assignCreditsMessage.style.color = 'red'; } } catch (error) { console.error('Error assigning credits:', error); assignCreditsMessage.textContent = 'An unexpected error occurred.'; assignCreditsMessage.style.color = 'red'; } }); editClientForm.addEventListener('submit', async (e) => { e.preventDefault(); editClientMessage.textContent = ''; // Clear previous messages const updatedName = editClientNameInput.value; const updatedEmail = editClientEmailInput.value; if (!currentClientId) { editClientMessage.textContent = 'Error: Client ID not found.'; editClientMessage.style.color = 'red'; return; } try { const response = await fetch('/api/update-client', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ clientId: currentClientId, name: updatedName, email: updatedEmail }), }); const data = await response.json(); if (response.ok) { editClientMessage.textContent = data.message || 'Client updated successfully!'; editClientMessage.style.color = 'green'; // Refresh displayed client info clientNameDisplay.textContent = updatedName; clientEmailDisplay.textContent = updatedEmail; } else { editClientMessage.textContent = data.message || 'Failed to update client.'; editClientMessage.style.color = 'red'; } } catch (error) { console.error('Error updating client:', error); editClientMessage.textContent = 'An unexpected error occurred.'; editClientMessage.style.color = 'red'; } }); deleteClientButton.addEventListener('click', async () => { if (!currentClientId) { editClientMessage.textContent = 'Error: Client ID not found for deletion.'; editClientMessage.style.color = 'red'; return; } if (confirm('Are you sure you want to delete this client and all their associated data? This action cannot be undone.')) { try { const response = await fetch('/api/delete-client', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ clientId: currentClientId }), }); const data = await response.json(); if (response.ok) { alert(data.message || 'Client deleted successfully!'); window.location.href = 'agency-dashboard.html'; // Redirect to dashboard } else { editClientMessage.textContent = data.message || 'Failed to delete client.'; editClientMessage.style.color = 'red'; } } catch (error) { console.error('Error deleting client:', error); editClientMessage.textContent = 'An unexpected error occurred during client deletion.'; editClientMessage.style.color = 'red'; } } }); document.getElementById('logout-link').addEventListener('click', async () => { await fetch('/api/logout', { method: 'POST' }); window.location.href = 'index.html'; }); // --- Client Keyword Rankings Tracker Logic --- const addRankingForm = document.getElementById('add-ranking-form'); const syncRankingsBtn = document.getElementById('sync-rankings-btn'); const rankingsLoading = document.getElementById('rankings-loading'); const rankingsEmpty = document.getElementById('rankings-empty'); const rankingsTableContainer = document.getElementById('rankings-table-container'); const rankingsTableBody = document.getElementById('rankings-table-body'); const rankingFormError = document.getElementById('ranking-form-error'); let currentKeywordRankings = []; async function fetchRankings() { if (!rankingsTableBody || !currentClientId) return; rankingsLoading.style.display = 'flex'; rankingsTableContainer.style.display = 'none'; rankingsEmpty.style.display = 'none'; try { const response = await fetch(`/api/keyword-rankings?clientId=${currentClientId}`, { method: 'GET' }); if (response.ok) { const data = await response.json(); const rankings = data.rankings || []; currentKeywordRankings = rankings; renderRankings(rankings); } else { console.error('Failed to fetch rankings.'); rankingsLoading.style.display = 'none'; rankingsEmpty.style.display = 'flex'; } } catch (err) { console.error('Error fetching rankings:', err); rankingsLoading.style.display = 'none'; rankingsEmpty.style.display = 'flex'; } } function renderRankings(rankings) { rankingsLoading.style.display = 'none'; if (rankings.length === 0) { rankingsEmpty.style.display = 'flex'; rankingsTableContainer.style.display = 'none'; return; } rankingsEmpty.style.display = 'none'; rankingsTableContainer.style.display = 'block'; rankingsTableBody.innerHTML = ''; rankings.forEach(item => { const row = document.createElement('tr'); row.style.borderBottom = '1px solid rgba(255,255,255,0.05)'; // Trend display let trendHtml = ``; if (item.trend > 0) { trendHtml = ` +${item.trend}`; } else if (item.trend < 0) { trendHtml = ` ${item.trend}`; } // Rank badge styling let rankColor = '#9ca3af'; if (item.rank <= 3) rankColor = '#fbbf24'; // Gold else if (item.rank <= 10) rankColor = '#3b82f6'; // Blue else if (item.rank <= 20) rankColor = '#10b981'; // Green const rankBadge = `#${item.rank}`; // Mini Sparkline SVG let sparklineHtml = 'No history'; if (item.history && item.history.length > 0) { const pts = item.history; const minRank = 1; const maxRank = 50; const width = 80; const height = 25; const step = width / (pts.length - 1); // Map points to SVG coordinates. Higher rank (lower number) should be higher visually (lower Y coordinate) const pointsStr = pts.map((p, idx) => { const x = idx * step; // Normalize Y between 2 and height-2. Better rank (smaller value) -> lower Y coordinate const val = p.rank; const normalizedY = 2 + ((val - minRank) / (maxRank - minRank)) * (height - 4); return `${x.toFixed(1)},${normalizedY.toFixed(1)}`; }).join(' '); sparklineHtml = ` `; } row.innerHTML = `
    ${item.keyword}
    ${item.town}
    ${item.service} ${rankBadge} ${trendHtml} ${sparklineHtml} `; rankingsTableBody.appendChild(row); }); // Bind delete handlers document.querySelectorAll('.delete-rank-btn').forEach(btn => { btn.addEventListener('click', async (e) => { const rankingId = btn.dataset.id; if (confirm('Are you sure you want to stop tracking this keyword?')) { btn.disabled = true; btn.innerHTML = ''; try { const response = await fetch('/api/keyword-rankings', { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ rankingId, clientId: currentClientId }) }); if (response.ok) { fetchRankings(); } else { const errData = await response.json(); alert(errData.message || 'Failed to delete tracked keyword.'); btn.disabled = false; btn.innerHTML = ''; } } catch (err) { console.error('Error deleting tracked keyword:', err); btn.disabled = false; btn.innerHTML = ''; } } }); }); } if (addRankingForm) { addRankingForm.addEventListener('submit', async (e) => { e.preventDefault(); rankingFormError.style.display = 'none'; const keyword = document.getElementById('ranking-keyword').value.trim(); const town = document.getElementById('ranking-town').value.trim(); const service = document.getElementById('ranking-service').value.trim(); if (!keyword || !town || !service || !currentClientId) return; const submitBtn = addRankingForm.querySelector('button[type="submit"]'); submitBtn.disabled = true; submitBtn.innerHTML = ' Adding...'; try { const response = await fetch('/api/keyword-rankings', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ keyword, town, service, clientId: currentClientId }) }); const data = await response.json(); if (response.ok) { document.getElementById('ranking-keyword').value = ''; document.getElementById('ranking-town').value = ''; document.getElementById('ranking-service').value = ''; fetchRankings(); } else { rankingFormError.textContent = data.message || 'Failed to add keyword tracking.'; rankingFormError.style.display = 'block'; } } catch (err) { console.error('Error adding ranking:', err); rankingFormError.textContent = 'An unexpected error occurred.'; rankingFormError.style.display = 'block'; } finally { submitBtn.disabled = false; submitBtn.innerHTML = ' Add Keyword'; } }); } if (syncRankingsBtn) { syncRankingsBtn.addEventListener('click', () => { fetchRankings(); }); } // Export Keyword Rankings to CSV const exportRankingsCsvBtn = document.getElementById('export-rankings-csv-btn'); if (exportRankingsCsvBtn) { exportRankingsCsvBtn.addEventListener('click', () => { if (currentKeywordRankings.length === 0) { alert('No keyword rankings to export.'); return; } let csvContent = 'data:text/csv;charset=utf-8,'; csvContent += 'Keyword,Town,Service,Current Rank,Previous Rank,Trend,Last Checked\n'; currentKeywordRankings.forEach(item => { const keywordEscaped = `"${item.keyword.replace(/"/g, '""')}"`; const townEscaped = `"${item.town.replace(/"/g, '""')}"`; const serviceEscaped = `"${item.service.replace(/"/g, '""')}"`; const lastCheckedFormatted = new Date(item.last_checked || new Date()).toLocaleString().replace(/,/g, ''); csvContent += `${keywordEscaped},${townEscaped},${serviceEscaped},${item.rank},${item.previousRank || ''},${item.trend || 0},${lastCheckedFormatted}\n`; }); const encodedUri = encodeURI(csvContent); const link = document.createElement('a'); link.setAttribute('href', encodedUri); link.setAttribute('download', `client_${currentClientId}_keyword_rankings_${new Date().toISOString().split('T')[0]}.csv`); document.body.appendChild(link); link.click(); document.body.removeChild(link); }); } // Bulk Import Keyword Rankings via CSV const bulkKeywordsCsvInput = document.getElementById('bulk-keywords-csv'); const bulkKeywordsStatus = document.getElementById('bulk-keywords-status'); if (bulkKeywordsCsvInput) { bulkKeywordsCsvInput.addEventListener('change', async (event) => { const file = event.target.files[0]; if (!file || !currentClientId) return; bulkKeywordsStatus.innerHTML = ' Parsing CSV...'; const reader = new FileReader(); reader.onload = async (e) => { const text = e.target.result; const lines = text.split('\n'); if (lines.length < 2) { bulkKeywordsStatus.innerHTML = 'CSV is empty or missing headers.'; return; } const headers = lines[0].split(',').map(h => h.trim().toLowerCase()); const keywordIdx = headers.indexOf('keyword'); const townIdx = headers.indexOf('town'); const serviceIdx = headers.indexOf('service'); if (keywordIdx === -1 || townIdx === -1) { bulkKeywordsStatus.innerHTML = 'CSV must contain "keyword" and "town" columns.'; return; } const keywordsList = []; for (let i = 1; i < lines.length; i++) { const line = lines[i].trim(); if (!line) continue; const cols = []; let inQuotes = false; let currentVal = ''; for (let c = 0; c < line.length; c++) { const char = line[c]; if (char === '"') { inQuotes = !inQuotes; } else if (char === ',' && !inQuotes) { cols.push(currentVal.trim()); currentVal = ''; } else { currentVal += char; } } cols.push(currentVal.trim()); const keyword = cols[keywordIdx]; const town = cols[townIdx]; const service = serviceIdx !== -1 ? (cols[serviceIdx] || 'General') : 'General'; if (keyword && town) { keywordsList.push({ keyword, town, service }); } } if (keywordsList.length === 0) { bulkKeywordsStatus.innerHTML = 'No valid rows found in CSV.'; return; } bulkKeywordsStatus.innerHTML = ` Uploading ${keywordsList.length} keywords...`; try { const response = await fetch('/api/keyword-rankings', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ keywords: keywordsList, clientId: currentClientId }) }); const result = await response.json(); if (response.ok) { bulkKeywordsStatus.innerHTML = ` Imported ${result.inserted?.length || 0} keywords successfully!`; fetchRankings(); } else { bulkKeywordsStatus.innerHTML = `Import failed: ${result.message}`; } } catch (err) { console.error('Error importing bulk keywords:', err); bulkKeywordsStatus.innerHTML = 'An unexpected error occurred during upload.'; } }; reader.readAsText(file); }); } fetchClientDetails();