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);
});