feat: portal self-audit SEO dashboard with history tracking
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 /admin/portal-seo to run SEO audits on nordabiznes.pl
using the same SEOAuditor used for company websites.
Tracks results over time for before/after comparison.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Maciej Pienczyn 2026-02-21 15:15:33 +01:00
parent 521122191d
commit dd4ab4c9fc
7 changed files with 730 additions and 0 deletions

View File

@ -32,3 +32,4 @@ from . import routes_social_publisher # noqa: E402, F401
from . import routes_data_quality # noqa: E402, F401
from . import routes_bulk_enrichment # noqa: E402, F401
from . import routes_website_discovery # noqa: E402, F401
from . import routes_portal_seo # noqa: E402, F401

View File

@ -0,0 +1,143 @@
"""
Portal SEO Audit Routes
========================
Self-audit of nordabiznes.pl using the same SEOAuditor used for company audits.
Tracks results over time for before/after comparison.
"""
import logging
import json
from datetime import datetime
from flask import render_template, request, redirect, url_for, flash, jsonify
from flask_login import login_required, current_user
from . import bp
from database import SessionLocal, PortalSEOAudit
from utils.decorators import audit_owner_required
logger = logging.getLogger(__name__)
PORTAL_URL = 'https://nordabiznes.pl'
@bp.route('/portal-seo')
@login_required
@audit_owner_required
def admin_portal_seo():
"""Portal SEO audit history dashboard"""
db = SessionLocal()
try:
audits = db.query(PortalSEOAudit).order_by(
PortalSEOAudit.audited_at.desc()
).all()
return render_template(
'admin/portal_seo.html',
audits=audits,
portal_url=PORTAL_URL
)
finally:
db.close()
@bp.route('/portal-seo/run', methods=['POST'])
@login_required
@audit_owner_required
def admin_portal_seo_run():
"""Run a new SEO audit on nordabiznes.pl using the existing SEOAuditor"""
import sys
import os
db = SessionLocal()
try:
# Use the same SEOAuditor that audits company websites
sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), 'scripts'))
from seo_audit import SEOAuditor
auditor = SEOAuditor()
result = auditor.audit_company({
'id': 0,
'name': 'NordaBiznes.pl',
'slug': 'nordabiznes-pl',
'website': PORTAL_URL,
'address_city': 'Wejherowo'
})
# Extract scores from audit result
ps = result.get('pagespeed', {})
ps_scores = ps.get('scores', {})
cwv = ps.get('core_web_vitals', {})
tech = result.get('technical', {})
on_page = result.get('on_page', {})
audit = PortalSEOAudit(
audited_at=datetime.now(),
url=PORTAL_URL,
pagespeed_performance=ps_scores.get('performance'),
pagespeed_seo=ps_scores.get('seo'),
pagespeed_accessibility=ps_scores.get('accessibility'),
pagespeed_best_practices=ps_scores.get('best_practices'),
overall_score=result.get('overall_score'),
on_page_score=on_page.get('score'),
technical_score=tech.get('score'),
local_seo_score=result.get('local_seo', {}).get('score'),
lcp_ms=cwv.get('lcp_ms'),
fcp_ms=cwv.get('fcp_ms'),
cls=cwv.get('cls'),
tbt_ms=cwv.get('tbt_ms'),
has_robots_txt=tech.get('has_robots_txt'),
has_sitemap=tech.get('has_sitemap'),
has_canonical=tech.get('has_canonical'),
has_structured_data=on_page.get('has_structured_data'),
has_og_tags=on_page.get('has_og_tags'),
has_ssl=tech.get('has_ssl'),
is_mobile_friendly=tech.get('is_mobile_friendly'),
has_hsts=result.get('security_headers', {}).get('has_hsts'),
has_csp=result.get('security_headers', {}).get('has_csp'),
full_results=result,
notes=request.form.get('notes', ''),
created_by=current_user.email
)
db.add(audit)
db.commit()
flash(f'Audyt SEO portalu zakończony. Wynik ogólny: {audit.overall_score or "N/A"}', 'success')
return redirect(url_for('admin.admin_portal_seo'))
except Exception as e:
db.rollback()
logger.error(f'Portal SEO audit failed: {e}', exc_info=True)
flash(f'Błąd audytu: {str(e)}', 'error')
return redirect(url_for('admin.admin_portal_seo'))
finally:
db.close()
@bp.route('/portal-seo/<int:audit_id>')
@login_required
@audit_owner_required
def admin_portal_seo_detail(audit_id):
"""View detailed results of a specific portal SEO audit"""
db = SessionLocal()
try:
audit = db.query(PortalSEOAudit).get(audit_id)
if not audit:
flash('Audyt nie znaleziony.', 'error')
return redirect(url_for('admin.admin_portal_seo'))
# Get previous audit for comparison
prev_audit = db.query(PortalSEOAudit).filter(
PortalSEOAudit.audited_at < audit.audited_at
).order_by(PortalSEOAudit.audited_at.desc()).first()
return render_template(
'admin/portal_seo_detail.html',
audit=audit,
prev_audit=prev_audit,
portal_url=PORTAL_URL
)
finally:
db.close()

View File

@ -5504,6 +5504,56 @@ class WebsiteDiscoveryCandidate(Base):
return f'<WebsiteDiscoveryCandidate {self.id} company={self.company_id} confidence={self.confidence}>'
class PortalSEOAudit(Base):
"""SEO audit snapshots for nordabiznes.pl portal itself.
Uses the same SEOAuditor as company audits for consistency."""
__tablename__ = 'portal_seo_audits'
id = Column(Integer, primary_key=True)
audited_at = Column(DateTime, default=datetime.now, nullable=False, index=True)
url = Column(String(500), nullable=False, default='https://nordabiznes.pl')
# PageSpeed scores (0-100)
pagespeed_performance = Column(Integer)
pagespeed_seo = Column(Integer)
pagespeed_accessibility = Column(Integer)
pagespeed_best_practices = Column(Integer)
# Overall scores from SEOAuditor
overall_score = Column(Integer)
on_page_score = Column(Integer)
technical_score = Column(Integer)
local_seo_score = Column(Integer)
# Core Web Vitals
lcp_ms = Column(Numeric(10, 2))
fcp_ms = Column(Numeric(10, 2))
cls = Column(Numeric(6, 4))
tbt_ms = Column(Numeric(10, 2))
# Key checks
has_robots_txt = Column(Boolean)
has_sitemap = Column(Boolean)
has_canonical = Column(Boolean)
has_structured_data = Column(Boolean)
has_og_tags = Column(Boolean)
has_ssl = Column(Boolean)
is_mobile_friendly = Column(Boolean)
# Security headers
has_hsts = Column(Boolean)
has_csp = Column(Boolean)
# Full audit results from SEOAuditor
full_results = Column(JSONB)
notes = Column(Text)
created_by = Column(String(100))
def __repr__(self):
return f'<PortalSEOAudit {self.id} {self.audited_at} perf={self.pagespeed_performance}>'
# ============================================================
# DATABASE INITIALIZATION
# ============================================================

View File

@ -0,0 +1,56 @@
-- Portal SEO Self-Audit History
-- Tracks SEO audit results for nordabiznes.pl over time
CREATE TABLE IF NOT EXISTS portal_seo_audits (
id SERIAL PRIMARY KEY,
audited_at TIMESTAMP NOT NULL DEFAULT NOW(),
url VARCHAR(500) NOT NULL DEFAULT 'https://nordabiznes.pl',
-- PageSpeed Insights scores (0-100)
pagespeed_performance INTEGER,
pagespeed_seo INTEGER,
pagespeed_accessibility INTEGER,
pagespeed_best_practices INTEGER,
-- Core Web Vitals
lcp_ms NUMERIC(10,2),
fcp_ms NUMERIC(10,2),
cls NUMERIC(6,4),
tbt_ms NUMERIC(10,2),
speed_index_ms NUMERIC(10,2),
-- On-page SEO checks
has_meta_title BOOLEAN,
has_meta_description BOOLEAN,
has_canonical BOOLEAN,
has_robots_txt BOOLEAN,
has_sitemap BOOLEAN,
has_structured_data BOOLEAN,
has_og_tags BOOLEAN,
has_ssl BOOLEAN,
is_mobile_friendly BOOLEAN,
-- Security headers
has_hsts BOOLEAN,
has_csp BOOLEAN,
has_x_frame BOOLEAN,
has_x_content_type BOOLEAN,
-- Content metrics
page_size_bytes INTEGER,
image_count INTEGER,
images_without_alt INTEGER,
-- Full audit data (JSON blob for details)
full_results JSONB,
-- Notes
notes TEXT,
created_by VARCHAR(100)
);
-- Index for time-series queries
CREATE INDEX IF NOT EXISTS idx_portal_seo_audits_date ON portal_seo_audits(audited_at DESC);
-- Grant permissions
GRANT ALL ON TABLE portal_seo_audits TO nordabiz_app;
GRANT USAGE, SELECT ON SEQUENCE portal_seo_audits_id_seq TO nordabiz_app;

View File

@ -0,0 +1,286 @@
{% extends "base.html" %}
{% block title %}SEO Portalu - Norda Biznes Partner{% endblock %}
{% block extra_css %}
<style>
.portal-seo-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--spacing-xl);
flex-wrap: wrap;
gap: var(--spacing-md);
}
.score-cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: var(--spacing-md);
margin-bottom: var(--spacing-xl);
}
.score-card {
background: white;
border-radius: var(--radius-lg);
padding: var(--spacing-lg);
text-align: center;
box-shadow: var(--shadow-sm);
border: 1px solid var(--border);
}
.score-card-value {
font-size: 2.5rem;
font-weight: 700;
line-height: 1;
margin-bottom: var(--spacing-xs);
}
.score-card-label {
font-size: var(--font-size-sm);
color: var(--text-secondary);
}
.score-card-delta {
font-size: var(--font-size-sm);
margin-top: var(--spacing-xs);
}
.score-good { color: var(--success); }
.score-ok { color: var(--warning); }
.score-bad { color: var(--error); }
.delta-up { color: var(--success); }
.delta-down { color: var(--error); }
.delta-same { color: var(--text-secondary); }
.check-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: var(--spacing-sm);
margin-bottom: var(--spacing-xl);
}
.check-item {
display: flex;
align-items: center;
gap: var(--spacing-sm);
padding: var(--spacing-sm) var(--spacing-md);
background: white;
border-radius: var(--radius);
border: 1px solid var(--border);
font-size: var(--font-size-sm);
}
.check-pass { border-left: 3px solid var(--success); }
.check-fail { border-left: 3px solid var(--error); }
.check-na { border-left: 3px solid var(--border); }
.audit-history {
background: white;
border-radius: var(--radius-lg);
overflow: hidden;
box-shadow: var(--shadow-sm);
border: 1px solid var(--border);
}
.audit-history table {
width: 100%;
border-collapse: collapse;
}
.audit-history th {
background: #f8fafc;
padding: var(--spacing-sm) var(--spacing-md);
text-align: left;
font-size: var(--font-size-sm);
font-weight: 600;
color: var(--text-secondary);
border-bottom: 2px solid var(--border);
}
.audit-history td {
padding: var(--spacing-sm) var(--spacing-md);
border-bottom: 1px solid var(--border);
font-size: var(--font-size-sm);
}
.audit-history tr:hover td {
background: #f8fafc;
}
.score-badge {
display: inline-block;
padding: 2px 8px;
border-radius: 4px;
font-weight: 600;
font-size: 12px;
}
.score-badge.good { background: #d1fae5; color: #065f46; }
.score-badge.ok { background: #fef3c7; color: #92400e; }
.score-badge.bad { background: #fee2e2; color: #991b1b; }
.run-form {
display: inline-flex;
align-items: center;
gap: var(--spacing-sm);
}
</style>
{% endblock %}
{% block content %}
<div class="portal-seo-header">
<div>
<h1 style="margin-bottom: var(--spacing-xs);">SEO Portalu</h1>
<p style="color: var(--text-secondary);">Audyt {{ portal_url }} - historia zmian w czasie</p>
</div>
<form method="POST" action="{{ url_for('admin.admin_portal_seo_run') }}" class="run-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="text" name="notes" placeholder="Notatka (opcjonalnie)" style="padding: 8px 12px; border: 1px solid var(--border); border-radius: var(--radius); font-size: var(--font-size-sm); width: 200px;">
<button type="submit" class="btn btn-primary" style="white-space: nowrap;">
Uruchom audyt
</button>
</form>
</div>
{% if audits %}
{% set latest = audits[0] %}
{% set prev = audits[1] if audits|length > 1 else None %}
<!-- Latest scores -->
<h2 style="font-size: var(--font-size-lg); margin-bottom: var(--spacing-md); color: var(--text-secondary);">
Ostatni audyt: {{ latest.audited_at.strftime('%d.%m.%Y %H:%M') }}
</h2>
<div class="score-cards">
{% macro score_card(label, value, prev_value) %}
<div class="score-card">
<div class="score-card-value {{ 'score-good' if value and value >= 90 else ('score-ok' if value and value >= 50 else 'score-bad') }}">
{{ value if value is not none else '—' }}
</div>
<div class="score-card-label">{{ label }}</div>
{% if prev_value is not none and value is not none %}
{% set delta = value - prev_value %}
<div class="score-card-delta {{ 'delta-up' if delta > 0 else ('delta-down' if delta < 0 else 'delta-same') }}">
{{ '+' if delta > 0 else '' }}{{ delta }}
</div>
{% endif %}
</div>
{% endmacro %}
{{ score_card('Performance', latest.pagespeed_performance, prev.pagespeed_performance if prev else None) }}
{{ score_card('SEO', latest.pagespeed_seo, prev.pagespeed_seo if prev else None) }}
{{ score_card('Accessibility', latest.pagespeed_accessibility, prev.pagespeed_accessibility if prev else None) }}
{{ score_card('Best Practices', latest.pagespeed_best_practices, prev.pagespeed_best_practices if prev else None) }}
{{ score_card('On-Page', latest.on_page_score, prev.on_page_score if prev else None) }}
{{ score_card('Technical', latest.technical_score, prev.technical_score if prev else None) }}
</div>
<!-- Key checks -->
<h3 style="margin-bottom: var(--spacing-md);">Kluczowe elementy SEO</h3>
<div class="check-grid">
{% macro check_item(label, value) %}
<div class="check-item {{ 'check-pass' if value == true else ('check-fail' if value == false else 'check-na') }}">
{% if value == true %}
<span style="color: var(--success);">&#10003;</span>
{% elif value == false %}
<span style="color: var(--error);">&#10007;</span>
{% else %}
<span style="color: var(--text-secondary);">?</span>
{% endif %}
{{ label }}
</div>
{% endmacro %}
{{ check_item('robots.txt', latest.has_robots_txt) }}
{{ check_item('sitemap.xml', latest.has_sitemap) }}
{{ check_item('Canonical URL', latest.has_canonical) }}
{{ check_item('Structured Data', latest.has_structured_data) }}
{{ check_item('Open Graph', latest.has_og_tags) }}
{{ check_item('SSL', latest.has_ssl) }}
{{ check_item('Mobile Friendly', latest.is_mobile_friendly) }}
{{ check_item('HSTS', latest.has_hsts) }}
{{ check_item('CSP', latest.has_csp) }}
</div>
<!-- Core Web Vitals -->
{% if latest.lcp_ms or latest.fcp_ms or latest.cls is not none %}
<h3 style="margin-bottom: var(--spacing-md);">Core Web Vitals</h3>
<div class="score-cards" style="margin-bottom: var(--spacing-xl);">
{% if latest.lcp_ms %}
<div class="score-card">
<div class="score-card-value {{ 'score-good' if latest.lcp_ms <= 2500 else ('score-ok' if latest.lcp_ms <= 4000 else 'score-bad') }}" style="font-size: 1.8rem;">
{{ '%.1f'|format(latest.lcp_ms / 1000) }}s
</div>
<div class="score-card-label">LCP</div>
</div>
{% endif %}
{% if latest.fcp_ms %}
<div class="score-card">
<div class="score-card-value {{ 'score-good' if latest.fcp_ms <= 1800 else ('score-ok' if latest.fcp_ms <= 3000 else 'score-bad') }}" style="font-size: 1.8rem;">
{{ '%.1f'|format(latest.fcp_ms / 1000) }}s
</div>
<div class="score-card-label">FCP</div>
</div>
{% endif %}
{% if latest.cls is not none %}
<div class="score-card">
<div class="score-card-value {{ 'score-good' if latest.cls <= 0.1 else ('score-ok' if latest.cls <= 0.25 else 'score-bad') }}" style="font-size: 1.8rem;">
{{ '%.3f'|format(latest.cls) }}
</div>
<div class="score-card-label">CLS</div>
</div>
{% endif %}
{% if latest.tbt_ms %}
<div class="score-card">
<div class="score-card-value {{ 'score-good' if latest.tbt_ms <= 200 else ('score-ok' if latest.tbt_ms <= 600 else 'score-bad') }}" style="font-size: 1.8rem;">
{{ latest.tbt_ms|int }}ms
</div>
<div class="score-card-label">TBT</div>
</div>
{% endif %}
</div>
{% endif %}
{% endif %}
<!-- History table -->
<h3 style="margin-bottom: var(--spacing-md);">Historia audytów</h3>
{% if audits %}
<div class="audit-history">
<table>
<thead>
<tr>
<th>Data</th>
<th>Perf</th>
<th>SEO</th>
<th>A11y</th>
<th>BP</th>
<th>On-Page</th>
<th>Tech</th>
<th>Notatka</th>
<th></th>
</tr>
</thead>
<tbody>
{% for a in audits %}
<tr>
<td>{{ a.audited_at.strftime('%d.%m.%Y %H:%M') }}</td>
{% macro score_td(val) %}
<td>
{% if val is not none %}
<span class="score-badge {{ 'good' if val >= 90 else ('ok' if val >= 50 else 'bad') }}">{{ val }}</span>
{% else %}—{% endif %}
</td>
{% endmacro %}
{{ score_td(a.pagespeed_performance) }}
{{ score_td(a.pagespeed_seo) }}
{{ score_td(a.pagespeed_accessibility) }}
{{ score_td(a.pagespeed_best_practices) }}
{{ score_td(a.on_page_score) }}
{{ score_td(a.technical_score) }}
<td style="max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">
{{ a.notes or '' }}
</td>
<td>
<a href="{{ url_for('admin.admin_portal_seo_detail', audit_id=a.id) }}" style="color: var(--primary); font-size: var(--font-size-sm);">
Szczegóły
</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div style="text-align: center; padding: var(--spacing-2xl); color: var(--text-secondary);">
<p>Brak audytów. Kliknij "Uruchom audyt" aby wykonać pierwszy audyt SEO portalu.</p>
</div>
{% endif %}
{% endblock %}

View File

@ -0,0 +1,188 @@
{% extends "base.html" %}
{% block title %}Audyt SEO #{{ audit.id }} - Norda Biznes Partner{% endblock %}
{% block extra_css %}
<style>
.detail-header { margin-bottom: var(--spacing-xl); }
.detail-section {
background: white;
border-radius: var(--radius-lg);
padding: var(--spacing-lg);
margin-bottom: var(--spacing-lg);
box-shadow: var(--shadow-sm);
border: 1px solid var(--border);
}
.detail-section h3 {
margin-bottom: var(--spacing-md);
padding-bottom: var(--spacing-sm);
border-bottom: 1px solid var(--border);
}
.detail-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: var(--spacing-md);
}
.detail-item {
display: flex;
justify-content: space-between;
padding: var(--spacing-xs) 0;
font-size: var(--font-size-sm);
}
.detail-item-label { color: var(--text-secondary); }
.detail-item-value { font-weight: 600; }
.compare-better { color: var(--success); }
.compare-worse { color: var(--error); }
.issues-list { list-style: none; padding: 0; }
.issues-list li {
padding: var(--spacing-sm) var(--spacing-md);
border-left: 3px solid var(--warning);
margin-bottom: var(--spacing-xs);
font-size: var(--font-size-sm);
background: #fffbeb;
border-radius: 0 var(--radius) var(--radius) 0;
}
.issues-list li.critical { border-left-color: var(--error); background: #fef2f2; }
pre.json-dump {
background: #1e293b;
color: #e2e8f0;
padding: var(--spacing-lg);
border-radius: var(--radius);
overflow-x: auto;
font-size: 12px;
max-height: 500px;
overflow-y: auto;
}
</style>
{% endblock %}
{% block content %}
<div class="detail-header">
<a href="{{ url_for('admin.admin_portal_seo') }}" style="color: var(--primary); text-decoration: none; font-size: var(--font-size-sm);">
&larr; Wróć do listy audytów
</a>
<h1 style="margin-top: var(--spacing-sm);">Audyt SEO #{{ audit.id }}</h1>
<p style="color: var(--text-secondary);">
{{ audit.url }} &mdash; {{ audit.audited_at.strftime('%d.%m.%Y %H:%M') }}
{% if audit.notes %} &mdash; {{ audit.notes }}{% endif %}
</p>
</div>
{% if audit.full_results %}
{% set r = audit.full_results %}
<!-- Issues -->
{% if r.get('issues') %}
<div class="detail-section">
<h3>Znalezione problemy ({{ r.issues|length }})</h3>
<ul class="issues-list">
{% for issue in r.issues %}
<li class="{{ 'critical' if issue.severity == 'critical' or issue.severity == 'high' else '' }}">
<strong>[{{ issue.severity|upper }}]</strong> {{ issue.message }}
</li>
{% endfor %}
</ul>
</div>
{% endif %}
<!-- PageSpeed -->
{% if r.get('pagespeed') %}
<div class="detail-section">
<h3>PageSpeed Insights</h3>
<div class="detail-grid">
{% set ps = r.pagespeed %}
{% set scores = ps.get('scores', {}) %}
{% for key in ['performance', 'seo', 'accessibility', 'best_practices'] %}
<div class="detail-item">
<span class="detail-item-label">{{ key|replace('_', ' ')|title }}</span>
<span class="detail-item-value">{{ scores.get(key, '—') }}</span>
</div>
{% endfor %}
{% set cwv = ps.get('core_web_vitals', {}) %}
{% for key in ['lcp_ms', 'fcp_ms', 'cls', 'tbt_ms', 'ttfb_ms'] %}
{% if cwv.get(key) is not none %}
<div class="detail-item">
<span class="detail-item-label">{{ key|upper|replace('_MS','')|replace('_','') }}</span>
<span class="detail-item-value">{{ cwv[key] }}{{ 'ms' if 'ms' in key else '' }}</span>
</div>
{% endif %}
{% endfor %}
</div>
</div>
{% endif %}
<!-- On-Page -->
{% if r.get('on_page') %}
<div class="detail-section">
<h3>On-Page SEO</h3>
<div class="detail-grid">
{% set op = r.on_page %}
{% for key, label in [('meta_title', 'Meta Title'), ('meta_description', 'Meta Description'), ('h1_text', 'H1'), ('h2_count', 'H2 Count'), ('total_images', 'Obrazy'), ('images_without_alt', 'Bez alt'), ('word_count', 'Słowa'), ('has_structured_data', 'Structured Data'), ('has_og_tags', 'Open Graph')] %}
{% if op.get(key) is not none %}
<div class="detail-item">
<span class="detail-item-label">{{ label }}</span>
<span class="detail-item-value">
{% if op[key] is sameas true %}&#10003;{% elif op[key] is sameas false %}&#10007;{% else %}{{ op[key] }}{% endif %}
</span>
</div>
{% endif %}
{% endfor %}
</div>
</div>
{% endif %}
<!-- Technical -->
{% if r.get('technical') %}
<div class="detail-section">
<h3>Technical SEO</h3>
<div class="detail-grid">
{% set t = r.technical %}
{% for key, label in [('has_robots_txt', 'robots.txt'), ('has_sitemap', 'sitemap.xml'), ('has_canonical', 'Canonical'), ('has_ssl', 'SSL'), ('is_mobile_friendly', 'Mobile Friendly'), ('is_indexable', 'Indexable'), ('viewport_configured', 'Viewport')] %}
{% if t.get(key) is not none %}
<div class="detail-item">
<span class="detail-item-label">{{ label }}</span>
<span class="detail-item-value" style="color: {{ 'var(--success)' if t[key] else 'var(--error)' }}">
{% if t[key] %}&#10003;{% else %}&#10007;{% endif %}
</span>
</div>
{% endif %}
{% endfor %}
</div>
</div>
{% endif %}
<!-- Local SEO -->
{% if r.get('local_seo') %}
<div class="detail-section">
<h3>Local SEO</h3>
<div class="detail-grid">
{% set ls = r.local_seo %}
{% for key, label in [('score', 'Wynik'), ('has_local_business_schema', 'LocalBusiness Schema'), ('nap_on_website', 'NAP na stronie'), ('has_google_maps_embed', 'Google Maps'), ('has_local_keywords', 'Lokalne słowa kluczowe')] %}
{% if ls.get(key) is not none %}
<div class="detail-item">
<span class="detail-item-label">{{ label }}</span>
<span class="detail-item-value">
{% if ls[key] is sameas true %}&#10003;{% elif ls[key] is sameas false %}&#10007;{% else %}{{ ls[key] }}{% endif %}
</span>
</div>
{% endif %}
{% endfor %}
</div>
</div>
{% endif %}
<!-- Raw JSON (collapsible) -->
<div class="detail-section">
<details>
<summary style="cursor: pointer; font-weight: 600;">Pełne dane audytu (JSON)</summary>
<pre class="json-dump">{{ r|tojson(indent=2) }}</pre>
</details>
</div>
{% else %}
<div class="detail-section">
<p style="color: var(--text-secondary);">Brak szczegółowych danych dla tego audytu.</p>
</div>
{% endif %}
{% endblock %}

View File

@ -1631,6 +1631,12 @@
</svg>
Audyt KRS
</a>
<a href="{{ url_for('admin.admin_portal_seo') }}" class="owner-only">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"/>
</svg>
SEO Portalu
</a>
</div>
</div>