fix: display hidden GBP data and fix reviews/photos rendering bugs
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

- Fix google_reviews_data bug: data is dict with 'reviews' key, not a list
  (was always hidden by 'is not mapping' guard)
- Add rating distribution bar chart from Places API review data
- Display google_photos_metadata table (author, dimensions, owner photos)
- Add audit diagnostics footer (source, version, errors)
- Show owner_response_time and keywords on individual GBP reviews
- Add Polish translations for Google Places business types (40+ types)
- Use primary_type_display for human-readable category names

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Maciej Pienczyn 2026-03-11 07:05:34 +01:00
parent 67c071cf1c
commit 1e5e492dce
2 changed files with 152 additions and 21 deletions

View File

@ -376,9 +376,42 @@ def gbp_audit_dashboard(slug):
analysis = db.query(CompanyWebsiteAnalysis).filter(
CompanyWebsiteAnalysis.company_id == company.id
).order_by(CompanyWebsiteAnalysis.analyzed_at.desc()).first()
# Polish translations for common Google Places types
_type_pl = {
'accounting': 'Biuro rachunkowe', 'airport': 'Lotnisko', 'atm': 'Bankomat',
'bakery': 'Piekarnia', 'bank': 'Bank', 'bar': 'Bar', 'beauty_salon': 'Salon kosmetyczny',
'book_store': 'Księgarnia', 'cafe': 'Kawiarnia', 'car_dealer': 'Dealer samochodowy',
'car_repair': 'Warsztat samochodowy', 'car_wash': 'Myjnia samochodowa',
'clothing_store': 'Sklep odzieżowy', 'construction_company': 'Firma budowlana',
'convenience_store': 'Sklep spożywczy', 'dentist': 'Dentysta', 'doctor': 'Lekarz',
'electrician': 'Elektryk', 'electronics_store': 'Sklep elektroniczny',
'establishment': 'Firma', 'finance': 'Finanse', 'florist': 'Kwiaciarnia',
'food': 'Gastronomia', 'furniture_store': 'Sklep meblowy', 'gas_station': 'Stacja benzynowa',
'general_contractor': 'Generalny wykonawca', 'grocery_or_supermarket': 'Sklep/supermarket',
'gym': 'Siłownia', 'hair_care': 'Fryzjer', 'hardware_store': 'Sklep budowlany',
'health': 'Zdrowie', 'home_goods_store': 'Sklep z wyposażeniem domu',
'insurance_agency': 'Agencja ubezpieczeniowa', 'lawyer': 'Prawnik',
'locksmith': 'Ślusarz', 'lodging': 'Nocleg', 'meal_delivery': 'Dostawa jedzenia',
'meal_takeaway': 'Jedzenie na wynos', 'moving_company': 'Firma przeprowadzkowa',
'painter': 'Malarz', 'parking': 'Parking', 'pet_store': 'Sklep zoologiczny',
'pharmacy': 'Apteka', 'physiotherapist': 'Fizjoterapeuta', 'plumber': 'Hydraulik',
'point_of_interest': 'Punkt zainteresowania', 'real_estate_agency': 'Agencja nieruchomości',
'restaurant': 'Restauracja', 'roofing_contractor': 'Dekarz',
'school': 'Szkoła', 'shoe_store': 'Sklep obuwniczy', 'shopping_mall': 'Centrum handlowe',
'spa': 'Spa', 'store': 'Sklep', 'supermarket': 'Supermarket',
'travel_agency': 'Biuro podróży', 'veterinary_care': 'Weterynarz',
'computer_store': 'Sklep komputerowy', 'it_services': 'Usługi IT',
'corporate_office': 'Biuro', 'consultant': 'Konsultant',
'marketing_agency': 'Agencja marketingowa', 'photographer': 'Fotograf',
'printing_service': 'Drukarnia', 'sign_shop': 'Reklama wizualna',
'auto_body_shop': 'Blacharnia', 'auto_parts_store': 'Sklep z częściami',
}
if analysis:
_ptype = getattr(analysis, 'google_primary_type', None) or ''
places_data = {
'primary_type': getattr(analysis, 'google_primary_type', None),
'primary_type': _ptype,
'primary_type_display': _type_pl.get(_ptype, _ptype.replace('_', ' ').title()) if _ptype else None,
'editorial_summary': getattr(analysis, 'google_editorial_summary', None),
'price_level': getattr(analysis, 'google_price_level', None),
'maps_links': getattr(analysis, 'google_maps_links', None),

View File

@ -1054,7 +1054,7 @@
{% 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>
<span style="color: var(--text-primary); font-weight: 500;">{{ places_data.primary_type_display or 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 %}
@ -1463,10 +1463,22 @@
{% endif %}
{% if review.has_owner_response and review.owner_response_text %}
<div style="margin-top: var(--spacing-xs); padding: var(--spacing-xs) var(--spacing-sm); background: var(--bg-tertiary); border-left: 3px solid var(--primary); border-radius: 0 var(--radius-sm) var(--radius-sm) 0;">
<div style="font-size: var(--font-size-xs); font-weight: 600; color: var(--text-primary); margin-bottom: 2px;">Odpowiedź właściciela:</div>
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 2px;">
<span style="font-size: var(--font-size-xs); font-weight: 600; color: var(--text-primary);">Odpowiedz wlasciciela:</span>
{% if review.owner_response_time %}
<span style="font-size: var(--font-size-xs); color: var(--text-tertiary);">{{ review.owner_response_time.strftime('%d.%m.%Y') }}</span>
{% endif %}
</div>
<p style="font-size: var(--font-size-xs); color: var(--text-secondary); margin: 0; line-height: 1.4;">{{ review.owner_response_text[:200] }}{% if review.owner_response_text|length > 200 %}...{% endif %}</p>
</div>
{% endif %}
{% if review.keywords %}
<div style="margin-top: var(--spacing-xs);">
{% for kw in review.keywords[:5] %}
<span style="display: inline-block; padding: 1px 6px; margin: 1px; background: #eff6ff; color: #1d4ed8; border-radius: var(--radius-sm); font-size: 10px;">{{ kw }}</span>
{% endfor %}
</div>
{% endif %}
</div>
{% endfor %}
</div>
@ -1967,41 +1979,113 @@
</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 %}
{# Google Reviews from Places API — data is dict with 'reviews' key #}
{% if places_data and places_data.google_reviews_data is mapping and places_data.google_reviews_data.get('reviews') %}
{% set api_reviews = places_data.google_reviews_data.reviews %}
<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)
Opinie z Google Places API ({{ places_data.google_reviews_data.get('total_from_api', api_reviews|length) }} z {{ places_data.google_reviews_data.get('total_reported', '?') }})
</h2>
{% for review in places_data.google_reviews_data[:5] %}
{# Rating distribution bar #}
{% if places_data.google_reviews_data.get('rating_distribution') %}
{% set rd = places_data.google_reviews_data.rating_distribution %}
{% set rd_total = rd.values()|sum %}
{% if rd_total > 0 %}
<div style="margin-bottom: var(--spacing-md);">
{% for star in [5, 4, 3, 2, 1] %}
<div style="display: flex; align-items: center; gap: var(--spacing-sm); margin-bottom: 3px; font-size: var(--font-size-xs);">
<span style="width: 16px; text-align: right; color: var(--text-tertiary);">{{ star }}★</span>
<div style="flex: 1; height: 8px; background: var(--border); border-radius: 4px; overflow: hidden;">
<div style="height: 100%; width: {{ (rd.get(star, 0) / rd_total * 100)|round }}%; background: #f59e0b; border-radius: 4px;"></div>
</div>
<span style="width: 24px; text-align: right; color: var(--text-secondary);">{{ rd.get(star, 0) }}</span>
</div>
{% endfor %}
</div>
{% endif %}
{% endif %}
{# Response rate from Places API data #}
{% if places_data.google_reviews_data.get('response_rate') is not none %}
<div style="margin-bottom: var(--spacing-md); font-size: var(--font-size-sm); color: var(--text-secondary);">
Odpowiedzi na opinie: {{ places_data.google_reviews_data.with_response|default(0) }} z {{ (places_data.google_reviews_data.with_response|default(0)) + (places_data.google_reviews_data.without_response|default(0)) }}
({{ '%.0f'|format(places_data.google_reviews_data.response_rate) }}%)
</div>
{% endif %}
{# Individual reviews #}
{% for review in api_reviews[: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>
<strong style="font-size: var(--font-size-sm);">{{ review.author|default('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>
<span style="color: {{ '#f59e0b' if i < (review.rating|default(0))|int else '#d1d5db' }}; font-size: 14px;"></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>
{% if review.relative_time %}
<span style="font-size: var(--font-size-xs); color: var(--text-tertiary);">{{ review.relative_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>
{% if review.text %}
<p style="font-size: var(--font-size-sm); color: var(--text-secondary); margin: 0; line-height: 1.5;">{{ review.text[:300] }}{% if review.text|length > 300 %}...{% endif %}</p>
{% endif %}
</div>
{% endfor %}
</div>
{% endif %}
{# Google Photos Metadata #}
{% if places_data and places_data.google_photos_metadata is mapping and places_data.google_photos_metadata.get('photos') %}
{% set photos_meta = places_data.google_photos_metadata %}
<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="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"/>
</svg>
Zdjecia w Google ({{ photos_meta.get('total_count', photos_meta.photos|length) }})
</h2>
{% if photos_meta.get('has_owner_photos') is not none %}
<div style="margin-bottom: var(--spacing-sm);">
<span style="display: inline-flex; align-items: center; gap: 4px; padding: 3px 10px; border-radius: var(--radius-sm); font-size: var(--font-size-xs); font-weight: 600;
{% if photos_meta.has_owner_photos %}background: #dcfce7; color: #166534;{% else %}background: #fef3c7; color: #92400e;{% endif %}">
{% if photos_meta.has_owner_photos %}✓ Wlasciciel dodal zdjecia{% else %}✗ Brak zdjec od wlasciciela{% endif %}
</span>
</div>
{% endif %}
<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: 8px; color: var(--text-tertiary); font-weight: 500;">#</th>
<th style="text-align: left; padding: 8px; color: var(--text-tertiary); font-weight: 500;">Autor</th>
<th style="text-align: center; padding: 8px; color: var(--text-tertiary); font-weight: 500;">Wymiary</th>
</tr>
</thead>
<tbody>
{% for photo in photos_meta.photos[:10] %}
<tr style="border-bottom: 1px solid var(--border);">
<td style="padding: 8px; color: var(--text-secondary);">{{ loop.index }}</td>
<td style="padding: 8px;">{{ photo.get('attribution', '—') }}</td>
<td style="padding: 8px; text-align: center; font-family: monospace; font-size: 11px; color: var(--text-secondary);">
{% if photo.get('width') and photo.get('height') %}{{ photo.width }}×{{ photo.height }}{% else %}—{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endif %}
<!-- Educational Info Section -->
<div class="info-section">
<div class="info-section-header">
@ -2080,11 +2164,25 @@
{% include 'partials/audit_ai_actions.html' %}
{% endwith %}
{% if audit.audit_errors %}
<!-- Audit Errors / Warnings -->
<div style="background: #fef3c7; padding: var(--spacing-md); border-radius: var(--radius-lg); margin-bottom: var(--spacing-md); border-left: 4px solid #f59e0b;">
<div style="font-size: var(--font-size-sm); font-weight: 600; color: #92400e; margin-bottom: var(--spacing-xs);">Uwagi z audytu</div>
<p style="font-size: var(--font-size-xs); color: #78350f; margin: 0;">{{ audit.audit_errors }}</p>
{# Audit Diagnostics #}
{% if audit.audit_errors or audit.audit_source or audit.audit_version %}
<div style="background: var(--bg-tertiary); padding: var(--spacing-md); border-radius: var(--radius-lg); margin-bottom: var(--spacing-md); font-size: var(--font-size-xs); color: var(--text-tertiary);">
<div style="display: flex; gap: var(--spacing-lg); flex-wrap: wrap; margin-bottom: {{ 'var(--spacing-sm)' if audit.audit_errors else '0' }};">
{% if audit.audit_source %}
<span>Zrodlo: <strong style="color: var(--text-secondary);">{{ audit.audit_source }}</strong></span>
{% endif %}
{% if audit.audit_version %}
<span>Wersja: <strong style="color: var(--text-secondary);">{{ audit.audit_version }}</strong></span>
{% endif %}
{% if audit.audit_date %}
<span>Data: <strong style="color: var(--text-secondary);">{{ audit.audit_date.strftime('%d.%m.%Y %H:%M') }}</strong></span>
{% endif %}
</div>
{% if audit.audit_errors %}
<div style="padding: var(--spacing-sm); background: #fef3c7; border-radius: var(--radius-sm); border-left: 3px solid #f59e0b; color: #78350f;">
<strong style="color: #92400e;">Uwagi:</strong> {{ audit.audit_errors }}
</div>
{% endif %}
</div>
{% endif %}