const statusEl = document.querySelector('#status'); const connectButton = document.querySelector('#connectButton'); const disconnectButton = document.querySelector('#disconnectButton'); const refreshAccountsButton = document.querySelector('#refreshAccounts'); const accountsEl = document.querySelector('#accounts'); const locationsEl = document.querySelector('#locations'); const businessCountEl = document.querySelector('#businessCount'); const auditEl = document.querySelector('#audit'); const auditScoreEl = document.querySelector('#auditScore'); const competitorForm = document.querySelector('#competitorForm'); const competitorQueryInput = document.querySelector('#competitorQuery'); const competitorsEl = document.querySelector('#competitors'); let selectedAccount = null; let selectedLocation = null; let currentAuditLocation = null; async function api(path, options) { const response = await fetch(path, options); const data = await response.json().catch(() => ({})); if (!response.ok) { throw new Error(data.error || `Request failed with ${response.status}`); } return data; } async function jsonApi(path, method, body) { return api(path, { method, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); } function setConnectionState(connected) { connectButton.hidden = connected; disconnectButton.hidden = !connected; statusEl.textContent = connected ? 'Google account connected.' : 'No Google account connected.'; } function showError(element, error) { if (element === locationsEl) { element.className = 'businessGrid error'; } else if (element === auditEl) { element.className = 'audit error'; } else if (element === competitorsEl) { element.className = 'competitorGrid error'; } else { element.className = 'list error'; } element.textContent = error.message; } function accountLabel(account) { return account.accountName || account.name || 'Unnamed account'; } function formatAddress(address) { if (!address) return ''; const lines = address.addressLines || []; const cityLine = [address.locality, address.administrativeArea, address.postalCode] .filter(Boolean) .join(', '); return [...lines, cityLine].filter(Boolean).join(', '); } function humanDate(value) { if (!value) return ''; const date = new Date(value); if (Number.isNaN(date.getTime())) return value; return date.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' }); } function starRating(value) { const ratings = { ONE: '1', TWO: '2', THREE: '3', FOUR: '4', FIVE: '5' }; return ratings[value] || value || 'No rating'; } function renderAccounts(accounts) { accountsEl.className = 'list'; accountsEl.innerHTML = ''; if (!accounts.length) { accountsEl.className = 'list empty'; accountsEl.textContent = 'No Business Profile accounts were returned for this Google user.'; return; } for (const account of accounts) { const button = document.createElement('button'); button.type = 'button'; button.className = 'account'; button.setAttribute('aria-selected', selectedAccount === account.name ? 'true' : 'false'); button.innerHTML = ` `; button.querySelector('.title').textContent = accountLabel(account); button.querySelector('.meta').textContent = `${account.name} ${account.type ? `- ${account.type}` : ''}`; button.addEventListener('click', () => loadLocations(account.name)); accountsEl.append(button); } } function renderLocations(locations) { locationsEl.className = 'businessGrid'; locationsEl.innerHTML = ''; businessCountEl.textContent = `${locations.length} found`; if (!locations.length) { locationsEl.className = 'businessGrid empty'; locationsEl.textContent = 'No businesses were returned for this account.'; return; } for (const location of locations) { const article = document.createElement('article'); article.className = 'business'; article.dataset.locationName = location.name; article.setAttribute('aria-selected', selectedLocation === location.name ? 'true' : 'false'); const address = formatAddress(location.storefrontAddress); const phone = location.phoneNumbers?.primaryPhone || ''; article.innerHTML = ` `; article.querySelector('.title').textContent = location.title || 'Untitled business'; article.querySelector('.location-id').textContent = location.name; article.querySelector('.store-code').textContent = location.storeCode ? `Store code: ${location.storeCode}` : ''; article.querySelector('.address').textContent = address; article.querySelector('.phone').textContent = phone; article.querySelector('.website').textContent = location.websiteUri || ''; article.querySelector('.auditButton').addEventListener('click', () => loadAudit(location.name)); locationsEl.append(article); } } function renderCompetitors(places) { competitorsEl.className = 'competitorGrid'; competitorsEl.innerHTML = ''; if (!places.length) { competitorsEl.className = 'competitorGrid empty'; competitorsEl.textContent = 'No competitors found for that search.'; return; } for (const place of places) { const card = document.createElement('article'); card.className = 'competitor'; card.innerHTML = `
Primary type
All public types
`; card.querySelector('.title').textContent = place.name || 'Unnamed place'; card.querySelector('.address').textContent = place.address; card.querySelector('.primary-type').textContent = place.primaryTypeDisplayName || place.primaryType || 'Not returned'; card.querySelector('.all-types').textContent = place.types.length ? place.types.join(', ') : 'No public types returned'; card.querySelector('.stats').textContent = [ place.rating ? `Rating: ${place.rating}` : '', place.userRatingCount ? `${place.userRatingCount} reviews` : '', place.businessStatus ? `Status: ${place.businessStatus}` : '' ].filter(Boolean).join(' - '); const links = card.querySelector('.competitorLinks'); for (const [label, href] of [ ['Maps', place.googleMapsUri], ['Website', place.websiteUri] ]) { if (!href) continue; const link = document.createElement('a'); link.href = href; link.target = '_blank'; link.rel = 'noreferrer'; link.textContent = label; links.append(link); } if (place.nationalPhoneNumber || place.internationalPhoneNumber) { const phone = document.createElement('span'); phone.className = 'meta'; phone.textContent = place.nationalPhoneNumber || place.internationalPhoneNumber; links.append(phone); } competitorsEl.append(card); } } function sectionError(section) { const message = document.createElement('p'); message.className = 'error sectionError'; message.textContent = section.error; return message; } function createSection(title, section) { const wrapper = document.createElement('section'); wrapper.className = 'auditSection'; wrapper.innerHTML = `

`; wrapper.querySelector('h3').textContent = title; wrapper.querySelector('.muted').textContent = section.ok ? 'Loaded' : section.skipped ? 'Skipped' : `Not available (${section.statusCode || 'error'})`; return wrapper; } function renderReviews(section) { const wrapper = createSection('Reviews', section); if (!section.ok) { wrapper.append(sectionError(section)); return wrapper; } const data = section.data; const stats = document.createElement('div'); stats.className = 'statGrid'; stats.innerHTML = ` ${data.count}reviews fetched ${data.averageRating ?? 'N/A'}average rating ${data.repliedCount}with replies ${data.unrepliedCount}without replies `; const list = document.createElement('div'); list.className = 'detailList'; for (const review of data.latest || []) { const item = document.createElement('article'); item.className = 'detailItem'; item.dataset.reviewName = review.name; item.innerHTML = `

`; item.querySelector('.title').textContent = `${starRating(review.starRating)} star - ${review.reviewer?.displayName || 'Anonymous'}`; item.querySelector('.meta').textContent = `Updated ${humanDate(review.updateTime || review.createTime)}`; item.querySelector('p').textContent = review.comment || 'No written comment.'; item.querySelector('.reply').textContent = review.reviewReply ? `Owner reply, updated ${humanDate(review.reviewReply.updateTime)}` : 'Owner reply'; item.querySelector('textarea').value = review.reviewReply?.comment || ''; item.querySelector('.saveReply').addEventListener('click', () => saveReviewReply(item)); item.querySelector('.deleteReply').disabled = !review.reviewReply; item.querySelector('.deleteReply').addEventListener('click', () => deleteReviewReply(item)); list.append(item); } wrapper.append(stats, list); return wrapper; } function renderServices(section) { const wrapper = createSection('Services', section); if (!section.ok) { wrapper.append(sectionError(section)); return wrapper; } const data = section.data; const stats = document.createElement('div'); stats.className = 'statGrid'; stats.innerHTML = ` ${data.serviceItemCount}service items ${data.source || 'serviceList'}source `; const list = document.createElement('div'); list.className = 'detailList compact'; for (const service of data.serviceItems || []) { const item = document.createElement('article'); item.className = 'detailItem'; item.innerHTML = `

`; item.querySelector('.title').textContent = service.name || 'Service'; item.querySelector('.meta').textContent = [ service.category ? `Category: ${service.category}` : '', service.isOffered === false ? 'Not offered' : 'Offered' ].filter(Boolean).join(' - '); item.querySelector('p').textContent = service.description || ''; list.append(item); } if (!data.serviceItemCount) { list.className = 'empty'; list.textContent = 'No service items returned for this location.'; } wrapper.append(stats, list); return wrapper; } function renderMedia(section) { const wrapper = createSection('Media', section); if (!section.ok) { wrapper.append(sectionError(section)); return wrapper; } const data = section.data; const stats = document.createElement('div'); stats.className = 'statGrid'; stats.innerHTML = ` ${data.totalMediaItemCount}total media items ${data.count}loaded in preview `; const grid = document.createElement('div'); grid.className = 'mediaGrid'; for (const media of data.latest || []) { const link = document.createElement('a'); link.href = media.googleUrl || media.sourceUrl || '#'; link.target = '_blank'; link.rel = 'noreferrer'; link.className = 'mediaItem'; link.innerHTML = ` `; link.querySelector('.title').textContent = media.mediaFormat || 'Media item'; link.querySelector('.meta').textContent = humanDate(media.createTime) || media.name || ''; grid.append(link); } wrapper.append(stats, grid); return wrapper; } function renderPosts(section) { const wrapper = createSection('Posts', section); if (!section.ok) { wrapper.append(sectionError(section)); return wrapper; } const data = section.data; const stats = document.createElement('div'); stats.className = 'statGrid'; stats.innerHTML = ` ${data.count}posts fetched `; const list = document.createElement('div'); list.className = 'detailList'; for (const post of data.latest || []) { const item = document.createElement('article'); item.className = 'detailItem'; item.innerHTML = `

`; item.querySelector('.title').textContent = `${post.topicType || 'Post'} - ${post.state || 'unknown state'}`; item.querySelector('.meta').textContent = humanDate(post.updateTime || post.createTime); item.querySelector('p').textContent = post.summary || post.callToAction?.url || 'No summary.'; list.append(item); } if (!data.count) { list.className = 'empty'; list.textContent = 'No local posts returned for this location.'; } wrapper.append(stats, list); return wrapper; } function renderAttributes(section) { const wrapper = createSection('Attributes', section); if (!section.ok) { wrapper.append(sectionError(section)); return wrapper; } const data = section.data; const stats = document.createElement('div'); stats.className = 'statGrid'; stats.innerHTML = ` ${data.count}attributes returned `; const list = document.createElement('div'); list.className = 'detailList compact'; for (const attribute of data.attributes || []) { const item = document.createElement('article'); item.className = 'detailItem'; item.innerHTML = ` `; item.querySelector('.title').textContent = attribute.attributeId || 'Attribute'; item.querySelector('.meta').textContent = attribute.valueType || JSON.stringify(attribute.values || attribute.repeatedEnumValue || attribute); list.append(item); } if (!data.count) { list.className = 'empty'; list.textContent = 'No attributes returned for this location.'; } wrapper.append(stats, list); return wrapper; } function renderFoodMenus(section) { const wrapper = createSection('Food menus', section); if (!section.ok) { wrapper.append(sectionError(section)); return wrapper; } const data = section.data; const stats = document.createElement('div'); stats.className = 'statGrid'; stats.innerHTML = ` ${data.menuCount}menus ${data.sectionCount}sections ${data.itemCount}items `; wrapper.append(stats); return wrapper; } function renderGoogleUpdated(section) { const wrapper = createSection('Google updates', section); if (!section.ok) { wrapper.append(sectionError(section)); return wrapper; } const data = section.data; const stats = document.createElement('div'); stats.className = 'statGrid'; stats.innerHTML = ` ${data.diffMask || 'None'}changed fields ${data.pendingMask || 'None'}pending fields ${data.serviceItemCount}updated service items `; const categoryBlock = document.createElement('div'); categoryBlock.className = 'detailItem'; categoryBlock.innerHTML = ` Google-updated categories `; categoryBlock.querySelector('.primary-category').textContent = data.categories?.primary ? `Primary: ${data.categories.primary}` : 'Primary: not returned'; categoryBlock.querySelector('.additional-categories').textContent = data.categories?.additional?.length ? `Additional: ${data.categories.additional.join(', ')}` : 'Additional: none returned'; const list = document.createElement('div'); list.className = 'detailList compact'; for (const service of data.serviceItems || []) { const item = document.createElement('article'); item.className = 'detailItem'; item.innerHTML = ` `; item.querySelector('.title').textContent = service.name; item.querySelector('.meta').textContent = [ service.category ? `Category: ${service.category}` : '', service.isOffered === false ? 'Not offered' : 'Offered' ].filter(Boolean).join(' - '); list.append(item); } if (!data.serviceItemCount) { list.className = 'empty'; list.textContent = data.diffMask?.includes('serviceItems') ? 'Google reported service item changes, but did not return item details in this response.' : 'No Google-updated service items returned.'; } wrapper.append(stats, categoryBlock, list); return wrapper; } function renderAudit(location, audit, sections = {}) { auditEl.className = 'audit'; auditEl.innerHTML = ''; auditScoreEl.textContent = `${audit.score}% complete`; const summary = document.createElement('div'); summary.className = 'auditSummary'; summary.innerHTML = `
`; summary.querySelector('.score').textContent = `${audit.passedCount}/${audit.totalCount}`; summary.querySelector('.muted').textContent = 'checks passed'; summary.querySelector('.title').textContent = location.title || 'Untitled business'; summary.querySelector('.location-id').textContent = location.name; const mapsLink = summary.querySelector('.maps-link'); if (location.metadata?.mapsUri) { const link = document.createElement('a'); link.href = location.metadata.mapsUri; link.target = '_blank'; link.rel = 'noreferrer'; link.textContent = 'Open on Google Maps'; mapsLink.append(link); } else { mapsLink.textContent = location.metadata?.placeId ? `Place ID: ${location.metadata.placeId}` : ''; } const checks = document.createElement('div'); checks.className = 'checks'; for (const check of audit.checks) { const row = document.createElement('div'); row.className = check.passed ? 'check passed' : 'check missing'; row.innerHTML = ` `; row.querySelector('.checkStatus').textContent = check.passed ? 'OK' : 'Missing'; row.querySelector('.title').textContent = check.label; row.querySelector('.meta').textContent = check.detail; checks.append(row); } const details = document.createElement('div'); details.className = 'auditSections'; details.append( renderReviews(sections.reviews), renderServices(sections.services), renderMedia(sections.media), renderPosts(sections.posts), renderAttributes(sections.attributes), renderFoodMenus(sections.foodMenus), renderGoogleUpdated(sections.googleUpdated) ); auditEl.append(summary, checks, details); } async function loadStatus() { const { connected } = await api('/api/status'); setConnectionState(connected); if (connected) { await loadAccounts(); } } async function loadAccounts() { accountsEl.className = 'list empty'; accountsEl.textContent = 'Loading accounts...'; locationsEl.className = 'businessGrid empty'; locationsEl.textContent = 'Select an account to load businesses.'; businessCountEl.textContent = ''; auditEl.className = 'audit empty'; auditEl.textContent = 'Select a business to run the first audit.'; auditScoreEl.textContent = ''; try { const { accounts } = await api('/api/accounts'); renderAccounts(accounts); } catch (error) { showError(accountsEl, error); } } async function loadLocations(accountName) { selectedAccount = accountName; selectedLocation = null; for (const account of accountsEl.querySelectorAll('.account')) { account.setAttribute('aria-selected', account.textContent.includes(accountName) ? 'true' : 'false'); } locationsEl.className = 'businessGrid empty'; locationsEl.textContent = 'Loading businesses...'; businessCountEl.textContent = ''; auditEl.className = 'audit empty'; auditEl.textContent = 'Select a business to run the first audit.'; auditScoreEl.textContent = ''; try { const { locations } = await api(`/api/locations?account=${encodeURIComponent(accountName)}`); renderLocations(locations); } catch (error) { businessCountEl.textContent = ''; showError(locationsEl, error); } } async function loadAudit(locationName) { selectedLocation = locationName; currentAuditLocation = locationName; for (const business of locationsEl.querySelectorAll('.business')) { business.setAttribute('aria-selected', business.dataset.locationName === locationName ? 'true' : 'false'); } auditEl.className = 'audit empty'; auditEl.textContent = 'Running audit...'; auditScoreEl.textContent = ''; try { const query = new URLSearchParams({ account: selectedAccount, location: locationName }); const { location, audit, sections } = await api(`/api/audit?${query.toString()}`); renderAudit(location, audit, sections); } catch (error) { showError(auditEl, error); } } async function saveReviewReply(item) { const status = item.querySelector('.replyStatus'); const saveButton = item.querySelector('.saveReply'); const deleteButton = item.querySelector('.deleteReply'); const textarea = item.querySelector('textarea'); const comment = textarea.value.trim(); if (!comment) { status.textContent = 'Reply cannot be empty.'; return; } saveButton.disabled = true; deleteButton.disabled = true; status.textContent = 'Saving...'; try { await jsonApi('/api/reviews/reply', 'PUT', { account: selectedAccount, location: selectedLocation, review: item.dataset.reviewName, comment }); status.textContent = 'Saved.'; await loadAudit(currentAuditLocation); } catch (error) { status.textContent = error.message; saveButton.disabled = false; deleteButton.disabled = false; } } async function deleteReviewReply(item) { const status = item.querySelector('.replyStatus'); const saveButton = item.querySelector('.saveReply'); const deleteButton = item.querySelector('.deleteReply'); saveButton.disabled = true; deleteButton.disabled = true; status.textContent = 'Deleting...'; try { await jsonApi('/api/reviews/reply', 'DELETE', { account: selectedAccount, location: selectedLocation, review: item.dataset.reviewName }); status.textContent = 'Deleted.'; await loadAudit(currentAuditLocation); } catch (error) { status.textContent = error.message; saveButton.disabled = false; deleteButton.disabled = false; } } disconnectButton.addEventListener('click', async () => { await api('/logout', { method: 'POST' }); setConnectionState(false); selectedAccount = null; selectedLocation = null; currentAuditLocation = null; accountsEl.className = 'list empty'; accountsEl.textContent = 'Connect a Google account to load accounts.'; locationsEl.className = 'businessGrid empty'; locationsEl.textContent = 'Select an account to load businesses.'; businessCountEl.textContent = ''; auditEl.className = 'audit empty'; auditEl.textContent = 'Select a business to run the first audit.'; auditScoreEl.textContent = ''; }); refreshAccountsButton.addEventListener('click', loadAccounts); competitorForm.addEventListener('submit', async (event) => { event.preventDefault(); const query = competitorQueryInput.value.trim(); if (!query) return; competitorsEl.className = 'competitorGrid empty'; competitorsEl.textContent = 'Searching competitors...'; try { const params = new URLSearchParams({ q: query, limit: '10' }); const { places } = await api(`/api/competitors/search?${params.toString()}`); renderCompetitors(places); } catch (error) { showError(competitorsEl, error); } }); loadStatus().catch((error) => { setConnectionState(false); showError(accountsEl, error); });