773 lines
25 KiB
JavaScript
773 lines
25 KiB
JavaScript
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 = `
|
|
<span class="title"></span>
|
|
<span class="meta"></span>
|
|
`;
|
|
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 = `
|
|
<span class="title"></span>
|
|
<span class="meta location-id"></span>
|
|
<span class="meta store-code"></span>
|
|
<span class="meta address"></span>
|
|
<span class="meta phone"></span>
|
|
<span class="meta website"></span>
|
|
<button class="button small auditButton" type="button">Run audit</button>
|
|
`;
|
|
|
|
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 = `
|
|
<span class="title"></span>
|
|
<span class="meta address"></span>
|
|
<div class="typeBlock">
|
|
<span class="typeLabel">Primary type</span>
|
|
<span class="typeValue primary-type"></span>
|
|
</div>
|
|
<div class="typeBlock">
|
|
<span class="typeLabel">All public types</span>
|
|
<span class="typeValue all-types"></span>
|
|
</div>
|
|
<div class="meta stats"></div>
|
|
<div class="competitorLinks"></div>
|
|
`;
|
|
|
|
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 = `
|
|
<div class="sectionHeader">
|
|
<h3></h3>
|
|
<span class="muted"></span>
|
|
</div>
|
|
`;
|
|
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 = `
|
|
<span><strong>${data.count}</strong><small>reviews fetched</small></span>
|
|
<span><strong>${data.averageRating ?? 'N/A'}</strong><small>average rating</small></span>
|
|
<span><strong>${data.repliedCount}</strong><small>with replies</small></span>
|
|
<span><strong>${data.unrepliedCount}</strong><small>without replies</small></span>
|
|
`;
|
|
|
|
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 = `
|
|
<span class="title"></span>
|
|
<span class="meta"></span>
|
|
<p></p>
|
|
<label class="replyBox">
|
|
<span class="meta reply"></span>
|
|
<textarea rows="3" maxlength="4096"></textarea>
|
|
</label>
|
|
<div class="reviewActions">
|
|
<button class="button small saveReply" type="button">Save reply</button>
|
|
<button class="button small deleteReply" type="button">Delete reply</button>
|
|
<span class="meta replyStatus" role="status"></span>
|
|
</div>
|
|
`;
|
|
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 = `
|
|
<span><strong>${data.serviceItemCount}</strong><small>service items</small></span>
|
|
<span><strong>${data.source || 'serviceList'}</strong><small>source</small></span>
|
|
`;
|
|
|
|
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 = `
|
|
<span class="title"></span>
|
|
<span class="meta"></span>
|
|
<p></p>
|
|
`;
|
|
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 = `
|
|
<span><strong>${data.totalMediaItemCount}</strong><small>total media items</small></span>
|
|
<span><strong>${data.count}</strong><small>loaded in preview</small></span>
|
|
`;
|
|
|
|
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 = `
|
|
<span class="title"></span>
|
|
<span class="meta"></span>
|
|
`;
|
|
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 = `
|
|
<span><strong>${data.count}</strong><small>posts fetched</small></span>
|
|
`;
|
|
|
|
const list = document.createElement('div');
|
|
list.className = 'detailList';
|
|
for (const post of data.latest || []) {
|
|
const item = document.createElement('article');
|
|
item.className = 'detailItem';
|
|
item.innerHTML = `
|
|
<span class="title"></span>
|
|
<span class="meta"></span>
|
|
<p></p>
|
|
`;
|
|
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 = `
|
|
<span><strong>${data.count}</strong><small>attributes returned</small></span>
|
|
`;
|
|
|
|
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 = `
|
|
<span class="title"></span>
|
|
<span class="meta"></span>
|
|
`;
|
|
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 = `
|
|
<span><strong>${data.menuCount}</strong><small>menus</small></span>
|
|
<span><strong>${data.sectionCount}</strong><small>sections</small></span>
|
|
<span><strong>${data.itemCount}</strong><small>items</small></span>
|
|
`;
|
|
|
|
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 = `
|
|
<span><strong>${data.diffMask || 'None'}</strong><small>changed fields</small></span>
|
|
<span><strong>${data.pendingMask || 'None'}</strong><small>pending fields</small></span>
|
|
<span><strong>${data.serviceItemCount}</strong><small>updated service items</small></span>
|
|
`;
|
|
|
|
const categoryBlock = document.createElement('div');
|
|
categoryBlock.className = 'detailItem';
|
|
categoryBlock.innerHTML = `
|
|
<span class="title">Google-updated categories</span>
|
|
<span class="meta primary-category"></span>
|
|
<span class="meta additional-categories"></span>
|
|
`;
|
|
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 = `
|
|
<span class="title"></span>
|
|
<span class="meta"></span>
|
|
`;
|
|
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 = `
|
|
<div>
|
|
<span class="score"></span>
|
|
<span class="muted"></span>
|
|
</div>
|
|
<div class="auditIdentity">
|
|
<span class="title"></span>
|
|
<span class="meta location-id"></span>
|
|
<span class="meta maps-link"></span>
|
|
</div>
|
|
`;
|
|
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 = `
|
|
<span class="checkStatus"></span>
|
|
<span>
|
|
<span class="title"></span>
|
|
<span class="meta"></span>
|
|
</span>
|
|
`;
|
|
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);
|
|
});
|