feat: enrich GBP audit detail page with comprehensive data display
Some checks are pending
NordaBiz Tests / Unit & Integration Tests (push) Waiting to run
NordaBiz Tests / E2E Tests (Playwright) (push) Blocked by required conditions
NordaBiz Tests / Smoke Tests (Production) (push) Blocked by required conditions
NordaBiz Tests / Send Failure Notification (push) Blocked by required conditions

Add opening hours, business status, Google types, website URL, Place ID,
rating/reviews/photos summary, website tracking indicators, Google attributes,
Places API reviews, smart recommendations engine, and benchmarks comparison
with other Norda Biznes members.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Maciej Pienczyn 2026-03-11 06:42:43 +01:00
parent 9bb9085810
commit cbb48f2726
2 changed files with 374 additions and 3 deletions

View File

@ -400,8 +400,69 @@ def gbp_audit_dashboard(slug):
'google_review_response_rate': float(analysis.google_review_response_rate) if getattr(analysis, 'google_review_response_rate', None) is not None else None,
'google_posts_data': getattr(analysis, 'google_posts_data', None),
'google_posts_count': getattr(analysis, 'google_posts_count', None),
# Additional data not previously exposed
'google_business_status': analysis.google_business_status,
'google_opening_hours': analysis.google_opening_hours,
'google_photos_count': analysis.google_photos_count,
'google_website': analysis.google_website,
'google_types': analysis.google_types,
'google_maps_url': analysis.google_maps_url,
'google_rating': float(analysis.google_rating) if analysis.google_rating is not None else None,
'google_reviews_count': analysis.google_reviews_count,
'google_attributes': getattr(analysis, 'google_attributes', None),
'google_reviews_data': getattr(analysis, 'google_reviews_data', None),
'google_photos_metadata': getattr(analysis, 'google_photos_metadata', None),
'google_place_id': analysis.google_place_id,
'has_google_analytics': getattr(analysis, 'has_google_analytics', None),
'has_google_tag_manager': getattr(analysis, 'has_google_tag_manager', None),
'has_google_maps_embed': getattr(analysis, 'has_google_maps_embed', None),
}
# Build GBP recommendations
gbp_recommendations = []
if analysis.google_name:
if not analysis.google_opening_hours:
gbp_recommendations.append({'severity': 'warning', 'text': 'Brak godzin otwarcia w Google. Klienci nie wiedza kiedy firma jest czynna.'})
if not analysis.google_phone:
gbp_recommendations.append({'severity': 'critical', 'text': 'Brak numeru telefonu w Google. To podstawowy sposob kontaktu klientow.'})
if (analysis.google_photos_count or 0) < 5:
gbp_recommendations.append({'severity': 'warning', 'text': 'Mniej niz 5 zdjec w profilu Google. Dodaj zdjecia — firmy ze zdjeciami otrzymuja 42%% wiecej zapytan o dojazd.'})
if (analysis.google_reviews_count or 0) < 5:
gbp_recommendations.append({'severity': 'warning', 'text': 'Mniej niz 5 opinii Google. Zachecaj klientow do wystawienia recenzji.'})
rr = float(analysis.google_review_response_rate) if analysis.google_review_response_rate is not None else None
if rr is not None and rr < 50:
gbp_recommendations.append({'severity': 'warning', 'text': 'Odpowiedzi na mniej niz 50%% opinii. Odpowiadanie na recenzje poprawia widocznosc.'})
if not analysis.google_website:
gbp_recommendations.append({'severity': 'info', 'text': 'Brak strony WWW w profilu Google. Dodaj link do strony firmy.'})
if analysis.google_business_status and analysis.google_business_status != 'OPERATIONAL':
gbp_recommendations.append({'severity': 'critical', 'text': 'Profil Google nie ma statusu OPERATIONAL. Sprawdz czy firma jest oznaczona jako czynna.'})
if not gbp_recommendations:
gbp_recommendations.append({'severity': 'success', 'text': 'Profil Google wyglada dobrze! Nie znaleziono powaznych problemow.'})
# GBP benchmarks — average across all members
gbp_benchmarks = {}
try:
from sqlalchemy import func as sqlfunc
bench = db.query(
sqlfunc.avg(CompanyWebsiteAnalysis.google_rating),
sqlfunc.avg(CompanyWebsiteAnalysis.google_reviews_count),
sqlfunc.avg(CompanyWebsiteAnalysis.google_photos_count),
sqlfunc.count(CompanyWebsiteAnalysis.id),
).join(Company, Company.id == CompanyWebsiteAnalysis.company_id
).filter(
Company.status == 'active',
CompanyWebsiteAnalysis.google_rating.isnot(None),
).first()
if bench and bench[3] > 1:
gbp_benchmarks = {
'count': bench[3],
'avg_rating': round(float(bench[0] or 0), 1),
'avg_reviews': round(float(bench[1] or 0)),
'avg_photos': round(float(bench[2] or 0)),
}
except Exception:
pass
# If no audit exists, we still render the page (template handles this)
# The user can trigger an audit from the dashboard
@ -417,7 +478,9 @@ def gbp_audit_dashboard(slug):
places_data=places_data,
can_audit=can_audit,
gbp_audit_available=GBP_AUDIT_AVAILABLE,
gbp_audit_version=GBP_AUDIT_VERSION
gbp_audit_version=GBP_AUDIT_VERSION,
gbp_recommendations=gbp_recommendations if analysis else [],
gbp_benchmarks=gbp_benchmarks if analysis else {}
)
finally:

View File

@ -1021,7 +1021,7 @@
</div>
</div>
{% if places_data and (places_data.primary_type or places_data.editorial_summary or places_data.price_level) %}
{% if places_data and (places_data.primary_type or places_data.editorial_summary or places_data.price_level or places_data.google_business_status or places_data.google_website or places_data.google_types) %}
<!-- Google Places Enrichment Data -->
<div style="background: var(--surface); padding: var(--spacing-md); border-radius: var(--radius-lg); box-shadow: var(--shadow); margin-bottom: var(--spacing-md);">
<h3 style="font-size: var(--font-size-sm); font-weight: 600; color: var(--text-primary); margin: 0 0 var(--spacing-sm) 0; display: flex; align-items: center; gap: var(--spacing-xs);">
@ -1030,20 +1030,63 @@
</svg>
Dane z Google Places
</h3>
{# Business status badge #}
{% if places_data.google_business_status %}
<div style="margin-bottom: var(--spacing-sm);">
{% set bs = places_data.google_business_status %}
<span style="display: inline-flex; align-items: center; gap: 6px; padding: 4px 12px; border-radius: 20px; font-size: 12px; font-weight: 600;
{% if bs == 'OPERATIONAL' %}background: #d1fae5; color: #065f46;
{% elif bs == 'CLOSED_TEMPORARILY' %}background: #fef3c7; color: #92400e;
{% else %}background: #fee2e2; color: #991b1b;{% endif %}">
<span style="width: 8px; height: 8px; border-radius: 50%;
{% if bs == 'OPERATIONAL' %}background: #10b981;
{% elif bs == 'CLOSED_TEMPORARILY' %}background: #f59e0b;
{% else %}background: #ef4444;{% endif %}"></span>
{% if bs == 'OPERATIONAL' %}Firma czynna
{% elif bs == 'CLOSED_TEMPORARILY' %}Tymczasowo zamknięta
{% elif bs == 'CLOSED_PERMANENTLY' %}Zamknięta na stałe
{% else %}{{ bs|replace('_', ' ')|title }}{% endif %}
</span>
</div>
{% endif %}
{% if places_data.primary_type %}
<div style="margin-bottom: var(--spacing-xs); font-size: var(--font-size-sm);">
<span style="color: var(--text-tertiary);">Typ:</span>
<span style="color: var(--text-primary); font-weight: 500;">{{ places_data.primary_type|replace('_', ' ')|title }}</span>
</div>
{% endif %}
{% if places_data.google_types and places_data.google_types is iterable and places_data.google_types is not string %}
<div style="margin-bottom: var(--spacing-xs); font-size: var(--font-size-sm);">
<span style="color: var(--text-tertiary);">Wszystkie kategorie:</span>
<span style="color: var(--text-secondary);">
{% for t in places_data.google_types[:8] %}
<span style="display: inline-block; padding: 1px 6px; margin: 1px; background: #eff6ff; color: #1d4ed8; border-radius: var(--radius-sm); font-size: var(--font-size-xs);">{{ t|replace('_', ' ')|title }}</span>
{% endfor %}
</span>
</div>
{% endif %}
{% if places_data.editorial_summary %}
<div style="margin-bottom: var(--spacing-xs); font-size: var(--font-size-sm);">
<span style="color: var(--text-tertiary);">Opis Google:</span>
<span style="color: var(--text-secondary);">{{ places_data.editorial_summary }}</span>
</div>
{% endif %}
{% if places_data.google_website %}
<div style="margin-bottom: var(--spacing-xs); font-size: var(--font-size-sm);">
<span style="color: var(--text-tertiary);">Strona WWW:</span>
<a href="{{ places_data.google_website }}" target="_blank" rel="noopener" style="color: #2563eb; text-decoration: none;">{{ places_data.google_website[:60] }}{% if places_data.google_website|length > 60 %}...{% endif %}</a>
</div>
{% endif %}
{% if places_data.google_place_id %}
<div style="margin-bottom: var(--spacing-xs); font-size: var(--font-size-sm);">
<span style="color: var(--text-tertiary);">Place ID:</span>
<span style="color: var(--text-secondary); font-family: monospace; font-size: 11px;">{{ places_data.google_place_id }}</span>
</div>
{% endif %}
{% if places_data.price_level is not none and places_data.price_level %}
<div style="font-size: var(--font-size-sm);">
<div style="margin-bottom: var(--spacing-xs); font-size: var(--font-size-sm);">
<span style="color: var(--text-tertiary);">Poziom cen:</span>
<span style="color: var(--text-primary);">
{% if places_data.price_level == 'PRICE_LEVEL_FREE' %}Bezpłatne
@ -1056,6 +1099,131 @@
</span>
</div>
{% endif %}
{# Google rating and reviews summary #}
{% if places_data.google_rating is not none %}
<div style="margin-top: var(--spacing-sm); padding-top: var(--spacing-sm); border-top: 1px solid var(--border); display: flex; gap: var(--spacing-lg); flex-wrap: wrap; font-size: var(--font-size-sm);">
<div>
<span style="color: var(--text-tertiary);">Ocena:</span>
<span style="font-weight: 700; color: #f59e0b;">
{% for i in range(5) %}
<span style="color: {{ '#f59e0b' if i < (places_data.google_rating|round) else '#d1d5db' }};">&#9733;</span>
{% endfor %}
{{ places_data.google_rating }}/5
</span>
</div>
{% if places_data.google_reviews_count %}
<div>
<span style="color: var(--text-tertiary);">Opinii:</span>
<span style="font-weight: 600; color: var(--text-primary);">{{ places_data.google_reviews_count }}</span>
</div>
{% endif %}
{% if places_data.google_photos_count %}
<div>
<span style="color: var(--text-tertiary);">Zdjęć:</span>
<span style="font-weight: 600; color: var(--text-primary);">{{ places_data.google_photos_count }}</span>
</div>
{% endif %}
</div>
{% endif %}
</div>
{% endif %}
{# Opening Hours Section #}
{% if places_data and places_data.google_opening_hours %}
<div style="background: var(--surface); padding: var(--spacing-md); border-radius: var(--radius-lg); box-shadow: var(--shadow); margin-bottom: var(--spacing-md);">
<h3 style="font-size: var(--font-size-sm); font-weight: 600; color: var(--text-primary); margin: 0 0 var(--spacing-sm) 0; display: flex; align-items: center; gap: var(--spacing-xs);">
<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
Godziny otwarcia w Google
</h3>
{% set hours = places_data.google_opening_hours %}
{% if hours is mapping and hours.get('weekday_text') %}
{# Standard Google Places API format #}
{% set day_names = {'Monday': 'Poniedziałek', 'Tuesday': 'Wtorek', 'Wednesday': 'Środa', 'Thursday': 'Czwartek', 'Friday': 'Piątek', 'Saturday': 'Sobota', 'Sunday': 'Niedziela'} %}
<div style="display: grid; grid-template-columns: auto 1fr; gap: 4px var(--spacing-md); font-size: var(--font-size-sm);">
{% for day_line in hours.weekday_text %}
{% set parts = day_line.split(': ', 1) %}
<span style="color: var(--text-tertiary); font-weight: 500;">{{ day_names.get(parts[0], parts[0]) if parts|length > 0 else day_line }}:</span>
<span style="color: var(--text-primary);">{{ parts[1] if parts|length > 1 else '' }}</span>
{% endfor %}
</div>
{% elif hours is mapping and hours.get('periods') %}
{# Alternative format with periods #}
{% set day_pl = ['Niedziela', 'Poniedziałek', 'Wtorek', 'Środa', 'Czwartek', 'Piątek', 'Sobota'] %}
<div style="display: grid; grid-template-columns: auto 1fr; gap: 4px var(--spacing-md); font-size: var(--font-size-sm);">
{% for period in hours.periods %}
<span style="color: var(--text-tertiary); font-weight: 500;">{{ day_pl[period.open.day|int] if period.open and period.open.day is defined else '?' }}:</span>
<span style="color: var(--text-primary);">
{% if period.open and period.open.time is defined %}
{{ period.open.time[:2] }}:{{ period.open.time[2:] }}
{% if period.close and period.close.time is defined %} {{ period.close.time[:2] }}:{{ period.close.time[2:] }}{% endif %}
{% else %}Zamknięte{% endif %}
</span>
{% endfor %}
</div>
{% elif hours is iterable and hours is not string and hours is not mapping %}
{# List format #}
<div style="font-size: var(--font-size-sm);">
{% for item in hours %}
<div style="color: var(--text-primary);">{{ item }}</div>
{% endfor %}
</div>
{% else %}
<div style="font-size: var(--font-size-sm); color: var(--text-secondary);">Godziny ustawione (format niestandardowy)</div>
{% endif %}
</div>
{% endif %}
{# Website Tracking Indicators #}
{% if places_data and (places_data.has_google_analytics is not none or places_data.has_google_tag_manager is not none or places_data.has_google_maps_embed is not none) %}
<div style="background: var(--surface); padding: var(--spacing-md); border-radius: var(--radius-lg); box-shadow: var(--shadow); margin-bottom: var(--spacing-md);">
<h3 style="font-size: var(--font-size-sm); font-weight: 600; color: var(--text-primary); margin: 0 0 var(--spacing-sm) 0; display: flex; align-items: center; gap: var(--spacing-xs);">
<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"/>
</svg>
Integracja ze stroną WWW
</h3>
<div style="display: flex; flex-wrap: wrap; gap: var(--spacing-sm);">
{% if places_data.has_google_analytics is not none %}
<div style="display: flex; align-items: center; gap: var(--spacing-xs); padding: var(--spacing-xs) var(--spacing-sm); border-radius: var(--radius); background: {{ '#dcfce7' if places_data.has_google_analytics else '#fee2e2' }};">
<span style="color: {{ '#10b981' if places_data.has_google_analytics else '#ef4444' }};">{{ '&#10003;' if places_data.has_google_analytics else '&#10007;' }}</span>
<span style="font-size: var(--font-size-sm);">Google Analytics</span>
</div>
{% endif %}
{% if places_data.has_google_tag_manager is not none %}
<div style="display: flex; align-items: center; gap: var(--spacing-xs); padding: var(--spacing-xs) var(--spacing-sm); border-radius: var(--radius); background: {{ '#dcfce7' if places_data.has_google_tag_manager else '#fee2e2' }};">
<span style="color: {{ '#10b981' if places_data.has_google_tag_manager else '#ef4444' }};">{{ '&#10003;' if places_data.has_google_tag_manager else '&#10007;' }}</span>
<span style="font-size: var(--font-size-sm);">Google Tag Manager</span>
</div>
{% endif %}
{% if places_data.has_google_maps_embed is not none %}
<div style="display: flex; align-items: center; gap: var(--spacing-xs); padding: var(--spacing-xs) var(--spacing-sm); border-radius: var(--radius); background: {{ '#dcfce7' if places_data.has_google_maps_embed else '#f3f4f6' }};">
<span style="color: {{ '#10b981' if places_data.has_google_maps_embed else '#6b7280' }};">{{ '&#10003;' if places_data.has_google_maps_embed else '&#10007;' }}</span>
<span style="font-size: var(--font-size-sm);">Mapa Google na stronie</span>
</div>
{% endif %}
</div>
</div>
{% endif %}
{# Google Attributes #}
{% if places_data and places_data.google_attributes and places_data.google_attributes is mapping %}
<div style="background: var(--surface); padding: var(--spacing-md); border-radius: var(--radius-lg); box-shadow: var(--shadow); margin-bottom: var(--spacing-md);">
<h3 style="font-size: var(--font-size-sm); font-weight: 600; color: var(--text-primary); margin: 0 0 var(--spacing-sm) 0; display: flex; align-items: center; gap: var(--spacing-xs);">
<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z"/>
</svg>
Atrybuty Google Business
</h3>
<div style="display: flex; flex-wrap: wrap; gap: var(--spacing-xs);">
{% for key, value in places_data.google_attributes.items() %}
<span style="padding: 4px 10px; background: {{ '#dcfce7' if value else '#f3f4f6' }}; color: {{ '#10b981' if value else '#6b7280' }}; border-radius: var(--radius-sm); font-size: var(--font-size-xs);">
{{ key|replace('_', ' ')|replace('.', ' ')|title }}{% if value is string %}: {{ value }}{% elif not value %} &#10007;{% endif %}
</span>
{% endfor %}
</div>
</div>
{% endif %}
@ -1741,6 +1909,146 @@
</div>
{% endif %}
{# Smart Recommendations (from backend analysis) #}
{% if gbp_recommendations and gbp_recommendations|length > 0 %}
<div style="background: var(--surface); padding: var(--spacing-lg); border-radius: var(--radius-lg); box-shadow: var(--shadow); margin-bottom: var(--spacing-md);">
<h2 class="section-title">
<svg width="24" height="24" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z"/>
</svg>
Automatyczne zalecenia
</h2>
<div style="display: flex; flex-direction: column; gap: var(--spacing-sm);">
{% for rec in gbp_recommendations %}
<div style="padding: var(--spacing-md); border-radius: var(--radius); display: flex; align-items: flex-start; gap: var(--spacing-sm);
{% if rec.severity == 'critical' %}background: #fef2f2; border-left: 4px solid #ef4444;
{% elif rec.severity == 'warning' %}background: #fffbeb; border-left: 4px solid #f59e0b;
{% elif rec.severity == 'success' %}background: #f0fdf4; border-left: 4px solid #10b981;
{% else %}background: #eff6ff; border-left: 4px solid #3b82f6;{% endif %}">
<span style="flex-shrink: 0; font-size: 18px;">
{% if rec.severity == 'critical' %}&#9888;
{% elif rec.severity == 'warning' %}&#9888;
{% elif rec.severity == 'success' %}&#10004;
{% else %}&#8505;{% endif %}
</span>
<div>
<span style="font-size: var(--font-size-xs); font-weight: 600; text-transform: uppercase; padding: 1px 6px; border-radius: var(--radius-sm); margin-bottom: 4px; display: inline-block;
{% if rec.severity == 'critical' %}background: #fee2e2; color: #991b1b;
{% elif rec.severity == 'warning' %}background: #fef3c7; color: #92400e;
{% elif rec.severity == 'success' %}background: #dcfce7; color: #166534;
{% else %}background: #dbeafe; color: #1e40af;{% endif %}">
{% if rec.severity == 'critical' %}Krytyczne
{% elif rec.severity == 'warning' %}Zalecenie
{% elif rec.severity == 'success' %}OK
{% else %}Informacja{% endif %}
</span>
<p style="font-size: var(--font-size-sm); color: var(--text-secondary); margin: 4px 0 0 0; line-height: 1.5;">{{ rec.text }}</p>
</div>
</div>
{% endfor %}
</div>
</div>
{% endif %}
{# Benchmarks — comparison with other members #}
{% if gbp_benchmarks and gbp_benchmarks.count is defined and gbp_benchmarks.count > 1 %}
<div style="background: var(--surface); padding: var(--spacing-lg); border-radius: var(--radius-lg); box-shadow: var(--shadow); margin-bottom: var(--spacing-md);">
<h2 class="section-title">
<svg width="24" height="24" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"/>
</svg>
Porownanie z innymi firmami Norda Biznes ({{ gbp_benchmarks.count }})
</h2>
<div style="overflow-x: auto;">
<table style="width: 100%; border-collapse: collapse; font-size: var(--font-size-sm);">
<thead>
<tr style="border-bottom: 2px solid var(--border);">
<th style="text-align: left; padding: 10px; color: var(--text-tertiary); font-weight: 500;">Metryka</th>
<th style="text-align: center; padding: 10px; color: var(--text-primary); font-weight: 600;">{{ company.name[:20] }}{% if company.name|length > 20 %}...{% endif %}</th>
<th style="text-align: center; padding: 10px; color: var(--text-tertiary); font-weight: 500;">Srednia Norda Biznes</th>
<th style="text-align: center; padding: 10px; color: var(--text-tertiary); font-weight: 500;">vs Srednia</th>
</tr>
</thead>
<tbody>
{# Rating #}
{% if places_data.google_rating is not none %}
{% set diff_rating = places_data.google_rating - gbp_benchmarks.avg_rating %}
<tr style="border-bottom: 1px solid var(--border);">
<td style="padding: 10px; font-weight: 500;">Ocena Google</td>
<td style="padding: 10px; text-align: center; font-weight: 700;">
<span style="color: #f59e0b;">&#9733;</span> {{ places_data.google_rating }}
</td>
<td style="padding: 10px; text-align: center; color: var(--text-secondary);">{{ gbp_benchmarks.avg_rating }}</td>
<td style="padding: 10px; text-align: center; font-weight: 600; color: {{ '#10b981' if diff_rating >= 0 else '#ef4444' }};">
{{ '&#9650;' if diff_rating > 0 else '&#9660;' if diff_rating < 0 else '&#9644;' }} {{ '%+.1f'|format(diff_rating) }}
</td>
</tr>
{% endif %}
{# Reviews count #}
{% if places_data.google_reviews_count is not none %}
{% set diff_reviews = (places_data.google_reviews_count or 0) - gbp_benchmarks.avg_reviews %}
<tr style="border-bottom: 1px solid var(--border);">
<td style="padding: 10px; font-weight: 500;">Liczba opinii</td>
<td style="padding: 10px; text-align: center; font-weight: 700;">{{ places_data.google_reviews_count }}</td>
<td style="padding: 10px; text-align: center; color: var(--text-secondary);">{{ gbp_benchmarks.avg_reviews }}</td>
<td style="padding: 10px; text-align: center; font-weight: 600; color: {{ '#10b981' if diff_reviews >= 0 else '#ef4444' }};">
{{ '&#9650;' if diff_reviews > 0 else '&#9660;' if diff_reviews < 0 else '&#9644;' }} {{ '%+d'|format(diff_reviews|int) }}
</td>
</tr>
{% endif %}
{# Photos count #}
{% if places_data.google_photos_count is not none %}
{% set diff_photos = (places_data.google_photos_count or 0) - gbp_benchmarks.avg_photos %}
<tr>
<td style="padding: 10px; font-weight: 500;">Liczba zdjec</td>
<td style="padding: 10px; text-align: center; font-weight: 700;">{{ places_data.google_photos_count }}</td>
<td style="padding: 10px; text-align: center; color: var(--text-secondary);">{{ gbp_benchmarks.avg_photos }}</td>
<td style="padding: 10px; text-align: center; font-weight: 600; color: {{ '#10b981' if diff_photos >= 0 else '#ef4444' }};">
{{ '&#9650;' if diff_photos > 0 else '&#9660;' if diff_photos < 0 else '&#9644;' }} {{ '%+d'|format(diff_photos|int) }}
</td>
</tr>
{% endif %}
</tbody>
</table>
</div>
</div>
{% endif %}
{# Google Reviews from Places API #}
{% if places_data and places_data.google_reviews_data and places_data.google_reviews_data is not mapping %}
<div style="background: var(--surface); padding: var(--spacing-lg); border-radius: var(--radius-lg); box-shadow: var(--shadow); margin-bottom: var(--spacing-md);">
<h2 class="section-title">
<svg width="24" height="24" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z"/>
</svg>
Opinie Google (z Places API)
</h2>
{% for review in places_data.google_reviews_data[:5] %}
<div style="padding: var(--spacing-md); border-bottom: 1px solid var(--border); {% if loop.last %}border-bottom: none;{% endif %}">
<div style="display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: var(--spacing-xs);">
<div style="display: flex; align-items: center; gap: var(--spacing-sm);">
<strong style="font-size: var(--font-size-sm);">{{ review.author_name if review.author_name is defined else (review.get('authorAttribution', {}).get('displayName', 'Anonim') if review is mapping else 'Anonim') }}</strong>
<div style="display: flex; gap: 2px;">
{% set rev_rating = review.rating if review.rating is defined else (review.get('rating', 0) if review is mapping else 0) %}
{% for i in range(5) %}
<span style="color: {{ '#f59e0b' if i < rev_rating|int else '#d1d5db' }}; font-size: 14px;">&#9733;</span>
{% endfor %}
</div>
</div>
{% set rev_time = review.relative_publish_time if review.relative_publish_time is defined else (review.get('relativePublishTimeDescription', '') if review is mapping else '') %}
{% if rev_time %}
<span style="font-size: var(--font-size-xs); color: var(--text-tertiary);">{{ rev_time }}</span>
{% endif %}
</div>
{% set rev_text = review.text if review.text is defined and review.text is string else (review.get('text', {}).get('text', '') if review is mapping else '') %}
{% if rev_text %}
<p style="font-size: var(--font-size-sm); color: var(--text-secondary); margin: 0; line-height: 1.5;">{{ rev_text[:300] }}{% if rev_text|length > 300 %}...{% endif %}</p>
{% endif %}
</div>
{% endfor %}
</div>
{% endif %}
<!-- Educational Info Section -->
<div class="info-section">
<div class="info-section-header">