GBP Audit: fetch Google data + detailed progress overlay

- Add fetch_google_business_data() to fetch fresh data from Google Places API
- Progress overlay shows all 10 data fields with actual values:
  * Place search, Rating, Reviews, Photos, Hours, Phone, Website, Status
- 5-second delay after completion for user to read results
- Fix opening hours display (show formatted weekday_text)
- Fix reviews scoring (integer-based: 3 base + 1/review + 1 bonus)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Maciej Pienczyn 2026-01-09 04:19:44 +01:00
parent 69bb6b839a
commit 6758e208d4
3 changed files with 768 additions and 19 deletions

37
app.py
View File

@ -136,7 +136,12 @@ except ImportError as e:
# GBP (Google Business Profile) audit service
try:
from gbp_audit_service import GBPAuditService, audit_company as gbp_audit_company, get_company_audit as gbp_get_company_audit
from gbp_audit_service import (
GBPAuditService,
audit_company as gbp_audit_company,
get_company_audit as gbp_get_company_audit,
fetch_google_business_data as gbp_fetch_google_data
)
GBP_AUDIT_AVAILABLE = True
GBP_AUDIT_VERSION = '1.0'
except ImportError as e:
@ -3892,8 +3897,21 @@ def api_gbp_audit_trigger():
logger.info(f"GBP audit triggered by {current_user.email} for company: {company.name} (ID: {company.id})")
# Option to fetch fresh Google data before audit
fetch_google = data.get('fetch_google', True)
force_refresh = data.get('force_refresh', False)
try:
# Run the audit
# Step 1: Fetch fresh Google Business data (if enabled)
fetch_result = None
if fetch_google:
logger.info(f"Fetching Google Business data for company {company.id}...")
fetch_result = gbp_fetch_google_data(db, company.id, force_refresh=force_refresh)
if not fetch_result.get('success') and not fetch_result.get('data', {}).get('cached'):
# Log warning but continue with audit
logger.warning(f"Google fetch warning for company {company.id}: {fetch_result.get('error')}")
# Step 2: Run the audit
result = gbp_audit_company(db, company.id, save=save_result)
# Build field status for response
@ -3918,7 +3936,7 @@ def api_gbp_audit_trigger():
else:
score_category = 'poor'
return jsonify({
response_data = {
'success': True,
'message': f'Audyt GBP dla firmy "{company.name}" został zakończony pomyślnie.',
'company_id': company.id,
@ -3940,7 +3958,18 @@ def api_gbp_audit_trigger():
'average_rating': float(result.average_rating) if result.average_rating else None,
'google_place_id': result.google_place_id
}
}), 200
}
# Include Google fetch results if performed
if fetch_result:
response_data['google_fetch'] = {
'success': fetch_result.get('success', False),
'steps': fetch_result.get('steps', []),
'data': fetch_result.get('data', {}),
'error': fetch_result.get('error')
}
return jsonify(response_data), 200
except ValueError as e:
return jsonify({

View File

@ -220,9 +220,20 @@ class GBPAuditService:
# Convert fields to JSON-serializable format
fields_status = {}
for name, field_status in result.fields.items():
# Keep dict values as-is for JSON serialization (e.g., opening hours)
# Convert other complex types to string
if field_status.value is None:
serialized_value = None
elif isinstance(field_status.value, (dict, list)):
serialized_value = field_status.value # Keep as dict/list for JSON
elif isinstance(field_status.value, (int, float, bool)):
serialized_value = field_status.value # Keep primitives as-is
else:
serialized_value = str(field_status.value)
fields_status[name] = {
'status': field_status.status,
'value': str(field_status.value) if field_status.value is not None else None,
'value': serialized_value,
'score': field_status.score,
'max_score': field_status.max_score
}
@ -573,7 +584,28 @@ class GBPAuditService:
)
def _check_reviews(self, company: Company, analysis: Optional[CompanyWebsiteAnalysis]) -> FieldStatus:
"""Check reviews presence and quality"""
"""
Check reviews presence and quality.
Scoring logic (max 9 points):
=============================
COMPLETE (9/9 pts):
- 5+ opinii AND ocena >= 4.0
PARTIAL (3-8/9 pts):
- Bazowo: 3 pkt za pierwszą opinię
- +1 pkt za każdą kolejną opinię (do 4 opinii = 6 pkt max)
- +1 pkt bonus jeśli ocena >= 4.0 (do 7 pkt max)
Przykłady:
- 1 opinia, ocena 3.5 3/9 pkt
- 1 opinia, ocena 5.0 4/9 pkt (3 + 1 bonus)
- 3 opinie, ocena 4.2 6/9 pkt (3 + 2 + 1 bonus)
- 4 opinie, ocena 4.5 7/9 pkt (3 + 3 + 1 bonus)
MISSING (0/9 pts):
- Brak opinii
"""
max_score = FIELD_WEIGHTS['reviews']
review_count = 0
@ -583,6 +615,7 @@ class GBPAuditService:
review_count = analysis.google_reviews_count or 0
rating = analysis.google_rating
# COMPLETE: 5+ reviews with good rating (4.0+) → full 9/9 points
if review_count >= REVIEW_THRESHOLDS['good'] and rating and float(rating) >= 4.0:
return FieldStatus(
field_name='reviews',
@ -592,8 +625,15 @@ class GBPAuditService:
max_score=max_score
)
# PARTIAL: 1-4 reviews → proportional scoring
if review_count >= REVIEW_THRESHOLDS['minimum']:
partial_score = max_score * 0.6
# Base: 3 pts for first review + 1 pt per additional review (max 6 pts for 4 reviews)
partial_score = min(3 + (review_count - 1), max_score - 3) # max 6 pts
# Bonus: +1 pt for good rating (>= 4.0)
if rating and float(rating) >= 4.0:
partial_score = min(partial_score + 1, max_score - 2) # max 7 pts
return FieldStatus(
field_name='reviews',
status='partial',
@ -603,6 +643,7 @@ class GBPAuditService:
recommendation='Zachęcaj klientów do zostawiania opinii. Więcej pozytywnych recenzji zwiększa zaufanie.'
)
# MISSING: no reviews → 0/9 points
return FieldStatus(
field_name='reviews',
status='missing',
@ -993,6 +1034,272 @@ def batch_audit_companies(
return results
# === Google Places API Integration ===
def fetch_google_business_data(
db: Session,
company_id: int,
force_refresh: bool = False
) -> Dict[str, Any]:
"""
Fetch fresh Google Business Profile data from Google Places API.
This function searches for the company on Google Places, retrieves
detailed business information, and updates the CompanyWebsiteAnalysis record.
Args:
db: Database session
company_id: Company ID to fetch data for
force_refresh: If True, fetch even if recent data exists
Returns:
Dict with:
- success: bool
- steps: List of step results with status
- data: Fetched Google data (if successful)
- error: Error message (if failed)
"""
import os
import requests
from datetime import datetime, timedelta
result = {
'success': False,
'steps': [],
'data': {},
'error': None
}
# Get company
company = db.query(Company).filter(Company.id == company_id).first()
if not company:
result['error'] = f'Firma o ID {company_id} nie znaleziona'
return result
# Check if we have recent data (less than 24 hours old)
if not force_refresh:
existing = db.query(CompanyWebsiteAnalysis).filter(
CompanyWebsiteAnalysis.company_id == company_id
).order_by(CompanyWebsiteAnalysis.analyzed_at.desc()).first()
if existing and existing.analyzed_at:
age = datetime.now() - existing.analyzed_at
if age < timedelta(hours=24) and existing.google_place_id:
result['success'] = True
result['steps'].append({
'step': 'cache_check',
'status': 'skipped',
'message': f'Dane pobrano {age.seconds // 3600}h temu - używam cache'
})
result['data'] = {
'google_place_id': existing.google_place_id,
'google_rating': float(existing.google_rating) if existing.google_rating else None,
'google_reviews_count': existing.google_reviews_count,
'google_photos_count': existing.google_photos_count,
'google_opening_hours': existing.google_opening_hours,
'cached': True
}
return result
# Get API key
api_key = os.getenv('GOOGLE_PLACES_API_KEY')
if not api_key:
result['error'] = 'Brak klucza API Google Places (GOOGLE_PLACES_API_KEY)'
result['steps'].append({
'step': 'api_key_check',
'status': 'error',
'message': result['error']
})
return result
result['steps'].append({
'step': 'api_key_check',
'status': 'complete',
'message': 'Klucz API skonfigurowany'
})
# Step 1: Search for place
result['steps'].append({
'step': 'find_place',
'status': 'in_progress',
'message': f'Szukam firmy "{company.name}" w Google Maps...'
})
city = company.address_city or 'Wejherowo'
search_query = f'{company.name} {city}'
try:
find_response = requests.get(
'https://maps.googleapis.com/maps/api/place/findplacefromtext/json',
params={
'input': search_query,
'inputtype': 'textquery',
'fields': 'place_id,name,formatted_address',
'language': 'pl',
'key': api_key,
},
timeout=15
)
find_response.raise_for_status()
find_data = find_response.json()
if find_data.get('status') != 'OK' or not find_data.get('candidates'):
result['steps'][-1]['status'] = 'warning'
result['steps'][-1]['message'] = f'Nie znaleziono firmy w Google Maps'
result['error'] = 'Firma nie ma profilu Google Business lub nazwa jest inna niż w Google'
return result
candidate = find_data['candidates'][0]
place_id = candidate.get('place_id')
google_name = candidate.get('name')
google_address = candidate.get('formatted_address')
result['steps'][-1]['status'] = 'complete'
result['steps'][-1]['message'] = f'Znaleziono: {google_name}'
result['data']['google_place_id'] = place_id
result['data']['google_name'] = google_name
result['data']['google_address'] = google_address
except requests.exceptions.Timeout:
result['steps'][-1]['status'] = 'error'
result['steps'][-1]['message'] = 'Timeout - Google API nie odpowiada'
result['error'] = 'Timeout podczas wyszukiwania w Google Places API'
return result
except Exception as e:
result['steps'][-1]['status'] = 'error'
result['steps'][-1]['message'] = f'Błąd: {str(e)}'
result['error'] = str(e)
return result
# Step 2: Get place details
result['steps'].append({
'step': 'get_details',
'status': 'in_progress',
'message': 'Pobieram szczegóły wizytówki...'
})
try:
fields = [
'rating',
'user_ratings_total',
'opening_hours',
'business_status',
'formatted_phone_number',
'website',
'photos',
]
details_response = requests.get(
'https://maps.googleapis.com/maps/api/place/details/json',
params={
'place_id': place_id,
'fields': ','.join(fields),
'language': 'pl',
'key': api_key,
},
timeout=15
)
details_response.raise_for_status()
details_data = details_response.json()
if details_data.get('status') != 'OK':
result['steps'][-1]['status'] = 'warning'
result['steps'][-1]['message'] = f'Nie udało się pobrać szczegółów'
result['error'] = f'Google Places API: {details_data.get("status")}'
return result
place = details_data.get('result', {})
# Extract data
rating = place.get('rating')
reviews_count = place.get('user_ratings_total')
photos = place.get('photos', [])
photos_count = len(photos) if photos else 0
opening_hours = place.get('opening_hours', {})
business_status = place.get('business_status')
phone = place.get('formatted_phone_number')
website = place.get('website')
result['data']['google_rating'] = rating
result['data']['google_reviews_count'] = reviews_count
result['data']['google_photos_count'] = photos_count
result['data']['google_opening_hours'] = opening_hours
result['data']['google_business_status'] = business_status
result['data']['google_phone'] = phone
result['data']['google_website'] = website
result['steps'][-1]['status'] = 'complete'
details_msg = []
if rating:
details_msg.append(f'Ocena: {rating}')
if reviews_count:
details_msg.append(f'{reviews_count} opinii')
if photos_count:
details_msg.append(f'{photos_count} zdjęć')
result['steps'][-1]['message'] = ', '.join(details_msg) if details_msg else 'Pobrano dane'
except requests.exceptions.Timeout:
result['steps'][-1]['status'] = 'error'
result['steps'][-1]['message'] = 'Timeout podczas pobierania szczegółów'
result['error'] = 'Timeout podczas pobierania szczegółów z Google Places API'
return result
except Exception as e:
result['steps'][-1]['status'] = 'error'
result['steps'][-1]['message'] = f'Błąd: {str(e)}'
result['error'] = str(e)
return result
# Step 3: Save to database
result['steps'].append({
'step': 'save_data',
'status': 'in_progress',
'message': 'Zapisuję dane w bazie...'
})
try:
# Get or create CompanyWebsiteAnalysis record
analysis = db.query(CompanyWebsiteAnalysis).filter(
CompanyWebsiteAnalysis.company_id == company_id
).first()
if not analysis:
analysis = CompanyWebsiteAnalysis(
company_id=company_id,
url=company.website,
analyzed_at=datetime.now()
)
db.add(analysis)
# Update Google fields
analysis.google_place_id = place_id
analysis.google_rating = rating
analysis.google_reviews_count = reviews_count
analysis.google_photos_count = photos_count
analysis.google_opening_hours = opening_hours if opening_hours else None
analysis.google_business_status = business_status
analysis.analyzed_at = datetime.now()
db.commit()
result['steps'][-1]['status'] = 'complete'
result['steps'][-1]['message'] = 'Dane zapisane pomyślnie'
result['success'] = True
except Exception as e:
db.rollback()
result['steps'][-1]['status'] = 'error'
result['steps'][-1]['message'] = f'Błąd zapisu: {str(e)}'
result['error'] = f'Błąd zapisu do bazy danych: {str(e)}'
return result
logger.info(
f"Google data fetched for company {company_id}: "
f"rating={rating}, reviews={reviews_count}, photos={photos_count}"
)
return result
# === Main for Testing ===
if __name__ == '__main__':

View File

@ -383,27 +383,149 @@
left: 0;
right: 0;
bottom: 0;
background: rgba(255, 255, 255, 0.9);
background: rgba(255, 255, 255, 0.95);
z-index: 1000;
align-items: center;
justify-content: center;
flex-direction: column;
gap: var(--spacing-md);
gap: var(--spacing-lg);
}
.loading-overlay.active {
display: flex;
}
.loading-spinner {
width: 48px;
height: 48px;
border: 4px solid var(--border);
.loading-content {
background: var(--surface);
padding: var(--spacing-xl);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-lg);
max-width: 400px;
width: 90%;
text-align: center;
}
.loading-header {
margin-bottom: var(--spacing-lg);
}
.loading-header h3 {
font-size: var(--font-size-lg);
color: var(--text-primary);
margin-bottom: var(--spacing-xs);
}
.loading-header p {
color: var(--text-secondary);
font-size: var(--font-size-sm);
}
.loading-steps {
text-align: left;
margin-bottom: var(--spacing-lg);
}
.loading-step {
display: flex;
align-items: center;
gap: var(--spacing-sm);
padding: var(--spacing-sm) 0;
border-bottom: 1px solid var(--border-light);
}
.loading-step.step-detail {
padding-left: var(--spacing-lg);
font-size: var(--font-size-xs);
}
.loading-step:last-child {
border-bottom: none;
}
.step-icon {
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.step-icon.pending {
color: var(--text-tertiary);
}
.step-icon.in_progress {
color: var(--primary);
}
.step-icon.complete {
color: var(--success);
}
.step-icon.error {
color: var(--error);
}
.step-icon.warning {
color: var(--warning);
}
.step-icon.missing {
color: var(--text-tertiary);
opacity: 0.6;
}
.step-icon.skipped {
color: var(--text-tertiary);
opacity: 0.5;
}
.step-spinner {
width: 18px;
height: 18px;
border: 2px solid var(--border);
border-top-color: var(--primary);
border-radius: 50%;
animation: spin 1s linear infinite;
}
.step-text {
flex: 1;
font-size: var(--font-size-sm);
}
.step-text.pending {
color: var(--text-tertiary);
}
.step-text.in_progress {
color: var(--text-primary);
font-weight: 500;
}
.step-text.complete {
color: var(--text-secondary);
}
.step-text.error {
color: var(--error);
}
.step-text.missing {
color: var(--text-tertiary);
font-style: italic;
}
.step-text.skipped {
color: var(--text-tertiary);
opacity: 0.6;
}
.step-text.warning {
color: var(--warning);
}
@keyframes spin {
to { transform: rotate(360deg); }
}
@ -741,7 +863,22 @@
</span>
</div>
{% if field_data.value %}
<div class="field-value">{{ field_data.value }}</div>
<div class="field-value">
{% if field_name == 'hours' and field_data.value is mapping and field_data.value.weekday_text %}
{# Format opening hours nicely #}
{% for day_hours in field_data.value.weekday_text[:5] %}
{{ day_hours }}{% if not loop.last %}<br>{% endif %}
{% endfor %}
{% if field_data.value.weekday_text|length > 5 %}
<br>...
{% endif %}
{% elif field_name == 'hours' and field_data.value is string and 'weekday_text' in field_data.value %}
{# Handle string representation of dict #}
<span class="hours-compact">Godziny ustawione</span>
{% else %}
{{ field_data.value }}
{% endif %}
</div>
{% endif %}
<div class="field-score">
<div class="field-score-bar">
@ -819,8 +956,99 @@
<!-- Loading Overlay -->
<div class="loading-overlay" id="loadingOverlay">
<div class="loading-spinner"></div>
<div class="loading-text">Trwa analiza wizytowki Google...</div>
<div class="loading-content">
<div class="loading-header">
<h3>Audyt Google Business Profile</h3>
<p>Pobieram dane z Google i analizuje wizytowke...</p>
</div>
<div class="loading-steps" id="loadingSteps">
<!-- Phase 1: Find Place -->
<div class="loading-step" id="step-find">
<div class="step-icon in_progress">
<div class="step-spinner"></div>
</div>
<span class="step-text in_progress">Szukam firmy w Google Maps...</span>
</div>
<!-- Phase 2: Fetch Details - individual data points -->
<div class="loading-step step-detail" id="step-rating">
<div class="step-icon pending">
<svg width="18" height="18" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<circle cx="12" cy="12" r="10" stroke-width="2"/>
</svg>
</div>
<span class="step-text pending">Ocena (rating)</span>
</div>
<div class="loading-step step-detail" id="step-reviews">
<div class="step-icon pending">
<svg width="18" height="18" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<circle cx="12" cy="12" r="10" stroke-width="2"/>
</svg>
</div>
<span class="step-text pending">Liczba opinii</span>
</div>
<div class="loading-step step-detail" id="step-photos">
<div class="step-icon pending">
<svg width="18" height="18" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<circle cx="12" cy="12" r="10" stroke-width="2"/>
</svg>
</div>
<span class="step-text pending">Zdjecia</span>
</div>
<div class="loading-step step-detail" id="step-hours">
<div class="step-icon pending">
<svg width="18" height="18" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<circle cx="12" cy="12" r="10" stroke-width="2"/>
</svg>
</div>
<span class="step-text pending">Godziny otwarcia</span>
</div>
<div class="loading-step step-detail" id="step-phone">
<div class="step-icon pending">
<svg width="18" height="18" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<circle cx="12" cy="12" r="10" stroke-width="2"/>
</svg>
</div>
<span class="step-text pending">Numer telefonu</span>
</div>
<div class="loading-step step-detail" id="step-website">
<div class="step-icon pending">
<svg width="18" height="18" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<circle cx="12" cy="12" r="10" stroke-width="2"/>
</svg>
</div>
<span class="step-text pending">Strona WWW</span>
</div>
<div class="loading-step step-detail" id="step-status">
<div class="step-icon pending">
<svg width="18" height="18" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<circle cx="12" cy="12" r="10" stroke-width="2"/>
</svg>
</div>
<span class="step-text pending">Status firmy</span>
</div>
<!-- Phase 3: Save -->
<div class="loading-step" id="step-save">
<div class="step-icon pending">
<svg width="18" height="18" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<circle cx="12" cy="12" r="10" stroke-width="2"/>
</svg>
</div>
<span class="step-text pending">Zapisuje dane w bazie</span>
</div>
<!-- Phase 4: Audit -->
<div class="loading-step" id="step-audit">
<div class="step-icon pending">
<svg width="18" height="18" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<circle cx="12" cy="12" r="10" stroke-width="2"/>
</svg>
</div>
<span class="step-text pending">Analizuje kompletnosc profilu</span>
</div>
</div>
</div>
</div>
<!-- Info Modal -->
@ -848,7 +1076,79 @@
const csrfToken = '{{ csrf_token() }}';
const companySlug = '{{ company.slug }}';
// All step IDs in order
const allSteps = [
'step-find',
'step-rating', 'step-reviews', 'step-photos', 'step-hours',
'step-phone', 'step-website', 'step-status',
'step-save', 'step-audit'
];
// Detail step labels (defaults)
const detailLabels = {
'step-rating': 'Ocena (rating)',
'step-reviews': 'Liczba opinii',
'step-photos': 'Zdjecia',
'step-hours': 'Godziny otwarcia',
'step-phone': 'Numer telefonu',
'step-website': 'Strona WWW',
'step-status': 'Status firmy'
};
// SVG icons for different states
const icons = {
pending: '<svg width="18" height="18" fill="none" stroke="currentColor" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10" stroke-width="2"/></svg>',
in_progress: '<div class="step-spinner"></div>',
complete: '<svg width="18" height="18" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg>',
error: '<svg width="18" height="18" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>',
warning: '<svg width="18" height="18" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/></svg>',
skipped: '<svg width="18" height="18" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 5l7 7-7 7M5 5l7 7-7 7"/></svg>',
missing: '<svg width="18" height="18" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 12H4"/></svg>'
};
function resetSteps() {
// Reset all steps to default labels and pending state
allSteps.forEach((stepId, index) => {
const stepEl = document.getElementById(stepId);
if (stepEl) {
const iconEl = stepEl.querySelector('.step-icon');
const textEl = stepEl.querySelector('.step-text');
// Reset label to default
if (detailLabels[stepId]) {
textEl.textContent = detailLabels[stepId];
}
if (index === 0) {
iconEl.className = 'step-icon in_progress';
iconEl.innerHTML = icons.in_progress;
textEl.className = 'step-text in_progress';
} else {
iconEl.className = 'step-icon pending';
iconEl.innerHTML = icons.pending;
textEl.className = 'step-text pending';
}
}
});
}
function updateStep(stepId, status, message) {
const stepEl = document.getElementById(stepId);
if (!stepEl) return;
const iconEl = stepEl.querySelector('.step-icon');
const textEl = stepEl.querySelector('.step-text');
iconEl.className = 'step-icon ' + status;
iconEl.innerHTML = icons[status] || icons.pending;
textEl.className = 'step-text ' + status;
if (message) {
textEl.textContent = message;
}
}
function showLoading() {
resetSteps();
document.getElementById('loadingOverlay').classList.add('active');
}
@ -872,6 +1172,82 @@ document.getElementById('infoModal')?.addEventListener('click', (e) => {
if (e.target.id === 'infoModal') closeInfoModal();
});
// Update detail steps with fetched data values
async function updateDetailSteps(googleData) {
const delay = 150; // ms between each step animation
// Rating
updateStep('step-rating', 'in_progress', 'Pobieram ocene...');
await new Promise(r => setTimeout(r, delay));
if (googleData.google_rating) {
updateStep('step-rating', 'complete', `Ocena: ${googleData.google_rating}/5`);
} else {
updateStep('step-rating', 'missing', 'Brak oceny');
}
// Reviews
updateStep('step-reviews', 'in_progress', 'Pobieram opinie...');
await new Promise(r => setTimeout(r, delay));
if (googleData.google_reviews_count) {
updateStep('step-reviews', 'complete', `Opinie: ${googleData.google_reviews_count}`);
} else {
updateStep('step-reviews', 'missing', 'Brak opinii');
}
// Photos
updateStep('step-photos', 'in_progress', 'Pobieram zdjecia...');
await new Promise(r => setTimeout(r, delay));
if (googleData.google_photos_count) {
updateStep('step-photos', 'complete', `Zdjecia: ${googleData.google_photos_count}`);
} else {
updateStep('step-photos', 'missing', 'Brak zdjec');
}
// Opening hours
updateStep('step-hours', 'in_progress', 'Pobieram godziny otwarcia...');
await new Promise(r => setTimeout(r, delay));
if (googleData.google_opening_hours && googleData.google_opening_hours.weekday_text) {
updateStep('step-hours', 'complete', 'Godziny otwarcia: ustawione');
} else {
updateStep('step-hours', 'missing', 'Brak godzin otwarcia');
}
// Phone
updateStep('step-phone', 'in_progress', 'Pobieram telefon...');
await new Promise(r => setTimeout(r, delay));
if (googleData.google_phone) {
updateStep('step-phone', 'complete', `Telefon: ${googleData.google_phone}`);
} else {
updateStep('step-phone', 'missing', 'Brak telefonu');
}
// Website
updateStep('step-website', 'in_progress', 'Pobieram strone WWW...');
await new Promise(r => setTimeout(r, delay));
if (googleData.google_website) {
// Truncate long URLs
const shortUrl = googleData.google_website.replace(/^https?:\/\//, '').slice(0, 30);
updateStep('step-website', 'complete', `WWW: ${shortUrl}...`);
} else {
updateStep('step-website', 'missing', 'Brak strony WWW');
}
// Business status
updateStep('step-status', 'in_progress', 'Pobieram status...');
await new Promise(r => setTimeout(r, delay));
if (googleData.google_business_status) {
const statusMap = {
'OPERATIONAL': 'Czynna',
'CLOSED_TEMPORARILY': 'Tymczasowo zamknieta',
'CLOSED_PERMANENTLY': 'Zamknieta na stale'
};
const statusText = statusMap[googleData.google_business_status] || googleData.google_business_status;
updateStep('step-status', 'complete', `Status: ${statusText}`);
} else {
updateStep('step-status', 'missing', 'Brak statusu');
}
}
async function runAudit() {
const btn = document.getElementById('runAuditBtn');
if (btn) {
@ -879,6 +1255,9 @@ async function runAudit() {
}
showLoading();
// Simulate step animation start
await new Promise(r => setTimeout(r, 300));
try {
const response = await fetch('/api/gbp/audit', {
method: 'POST',
@ -886,16 +1265,50 @@ async function runAudit() {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken
},
body: JSON.stringify({ slug: companySlug })
body: JSON.stringify({ slug: companySlug, force_refresh: true })
});
const data = await response.json();
hideLoading();
// Handle find_place step
if (data.google_fetch && data.google_fetch.steps) {
const findStep = data.google_fetch.steps.find(s => s.step === 'find_place');
if (findStep) {
updateStep('step-find', findStep.status, findStep.message);
}
}
// If we have Google data, animate the detail steps with actual values
if (data.google_fetch && data.google_fetch.data && data.google_fetch.success) {
await updateDetailSteps(data.google_fetch.data);
// Save step
const saveStep = data.google_fetch.steps.find(s => s.step === 'save_data');
if (saveStep) {
updateStep('step-save', saveStep.status, saveStep.message);
}
} else if (data.google_fetch && data.google_fetch.steps) {
// Mark detail steps as skipped if no Google data
const detailStepIds = ['step-rating', 'step-reviews', 'step-photos', 'step-hours', 'step-phone', 'step-website', 'step-status'];
for (const stepId of detailStepIds) {
updateStep(stepId, 'skipped', detailLabels[stepId] + ' (pominiety)');
}
}
// Update audit step
if (response.ok && data.success) {
showInfoModal('Audyt zakonczone', 'Audyt wizytowki Google zostal zakonczony pomyslnie. Strona zostanie odswiezona.', true);
updateStep('step-save', 'complete', 'Dane zapisane');
updateStep('step-audit', 'complete', `Analiza zakonczona: ${data.audit?.total_score || 0}/100`);
// Wait 5 seconds so user can read the progress steps
await new Promise(r => setTimeout(r, 5000));
hideLoading();
showInfoModal('Audyt zakonczony', 'Audyt wizytowki Google zostal zakonczony pomyslnie. Strona zostanie odswiezona.', true);
setTimeout(() => location.reload(), 1500);
} else {
updateStep('step-audit', 'error', 'Blad audytu');
// Wait 5 seconds so user can see what failed
await new Promise(r => setTimeout(r, 5000));
hideLoading();
showInfoModal('Blad', data.error || 'Wystapil nieznany blad podczas audytu.', false);
if (btn) btn.disabled = false;
}