diff --git a/app.py b/app.py index 7e1fb9d..1c798b7 100644 --- a/app.py +++ b/app.py @@ -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({ diff --git a/gbp_audit_service.py b/gbp_audit_service.py index 2a891a0..3454262 100644 --- a/gbp_audit_service.py +++ b/gbp_audit_service.py @@ -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__': diff --git a/templates/gbp_audit.html b/templates/gbp_audit.html index ac0813e..af8fd02 100644 --- a/templates/gbp_audit.html +++ b/templates/gbp_audit.html @@ -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 @@ {% if field_data.value %} -
{{ field_data.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 %}
{% endif %} + {% endfor %} + {% if field_data.value.weekday_text|length > 5 %} +
... + {% endif %} + {% elif field_name == 'hours' and field_data.value is string and 'weekday_text' in field_data.value %} + {# Handle string representation of dict #} + Godziny ustawione + {% else %} + {{ field_data.value }} + {% endif %} +
{% endif %}
@@ -819,8 +956,99 @@
-
-
Trwa analiza wizytowki Google...
+
+
+

Audyt Google Business Profile

+

Pobieram dane z Google i analizuje wizytowke...

+
+
+ +
+
+
+
+ Szukam firmy w Google Maps... +
+ + +
+
+ + + +
+ Ocena (rating) +
+
+
+ + + +
+ Liczba opinii +
+
+
+ + + +
+ Zdjecia +
+
+
+ + + +
+ Godziny otwarcia +
+
+
+ + + +
+ Numer telefonu +
+
+
+ + + +
+ Strona WWW +
+
+
+ + + +
+ Status firmy +
+ + +
+
+ + + +
+ Zapisuje dane w bazie +
+ + +
+
+ + + +
+ Analizuje kompletnosc profilu +
+
+
@@ -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: '', + in_progress: '
', + complete: '', + error: '', + warning: '', + skipped: '', + missing: '' +}; + +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; }