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
- google_places_service.py: Google Places API integration - competitor_monitoring_service.py: Competitor tracking service - scripts/competitor_monitor_cron.py, scripts/generate_audit_report.py - blueprints/admin/routes_competitors.py, templates/admin/competitor_dashboard.html Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
536 lines
18 KiB
HTML
536 lines
18 KiB
HTML
{% extends "base.html" %}
|
|
|
|
{% block title %}Monitoring Konkurencji - Panel Admina{% endblock %}
|
|
|
|
{% block extra_css %}
|
|
<style>
|
|
.dashboard-header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: flex-start;
|
|
margin-bottom: var(--spacing-xl);
|
|
flex-wrap: wrap;
|
|
gap: var(--spacing-md);
|
|
}
|
|
|
|
.dashboard-header h1 {
|
|
font-size: var(--font-size-2xl);
|
|
color: var(--text-primary);
|
|
margin-bottom: var(--spacing-xs);
|
|
}
|
|
|
|
.dashboard-header p {
|
|
color: var(--text-secondary);
|
|
font-size: var(--font-size-sm);
|
|
}
|
|
|
|
.stats-row {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
|
gap: var(--spacing-md);
|
|
margin-bottom: var(--spacing-xl);
|
|
}
|
|
|
|
.stat-card {
|
|
background: var(--surface);
|
|
border-radius: var(--radius-lg);
|
|
padding: var(--spacing-lg);
|
|
box-shadow: var(--shadow-sm);
|
|
text-align: center;
|
|
}
|
|
|
|
.stat-value {
|
|
font-size: var(--font-size-2xl);
|
|
font-weight: 700;
|
|
color: var(--primary);
|
|
}
|
|
|
|
.stat-label {
|
|
font-size: var(--font-size-sm);
|
|
color: var(--text-secondary);
|
|
margin-top: var(--spacing-xs);
|
|
}
|
|
|
|
.section-title {
|
|
font-size: var(--font-size-xl);
|
|
font-weight: 600;
|
|
color: var(--text-primary);
|
|
margin-bottom: var(--spacing-md);
|
|
display: flex;
|
|
align-items: center;
|
|
gap: var(--spacing-sm);
|
|
}
|
|
|
|
.company-competitor-card {
|
|
background: var(--surface);
|
|
border-radius: var(--radius-lg);
|
|
padding: var(--spacing-lg);
|
|
box-shadow: var(--shadow-sm);
|
|
margin-bottom: var(--spacing-md);
|
|
border-left: 4px solid var(--primary);
|
|
}
|
|
|
|
.company-competitor-header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
margin-bottom: var(--spacing-md);
|
|
}
|
|
|
|
.company-competitor-header h3 {
|
|
font-size: var(--font-size-lg);
|
|
font-weight: 600;
|
|
color: var(--text-primary);
|
|
}
|
|
|
|
.competitor-count-badge {
|
|
font-size: var(--font-size-sm);
|
|
padding: var(--spacing-xs) var(--spacing-sm);
|
|
background: var(--bg-tertiary);
|
|
border-radius: var(--radius);
|
|
color: var(--text-secondary);
|
|
}
|
|
|
|
.competitors-table {
|
|
width: 100%;
|
|
border-collapse: collapse;
|
|
font-size: var(--font-size-sm);
|
|
}
|
|
|
|
.competitors-table th {
|
|
text-align: left;
|
|
padding: var(--spacing-sm) var(--spacing-md);
|
|
color: var(--text-secondary);
|
|
font-weight: 500;
|
|
border-bottom: 2px solid var(--border);
|
|
font-size: var(--font-size-xs);
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.5px;
|
|
}
|
|
|
|
.competitors-table td {
|
|
padding: var(--spacing-sm) var(--spacing-md);
|
|
border-bottom: 1px solid var(--border);
|
|
color: var(--text-primary);
|
|
}
|
|
|
|
.competitors-table tr:last-child td {
|
|
border-bottom: none;
|
|
}
|
|
|
|
.competitors-table tr:hover td {
|
|
background: var(--bg-tertiary);
|
|
}
|
|
|
|
.rating-stars {
|
|
color: #f59e0b;
|
|
font-weight: 600;
|
|
}
|
|
|
|
.comparison-row {
|
|
background: rgba(168, 85, 247, 0.05);
|
|
font-weight: 600;
|
|
}
|
|
|
|
.comparison-row td {
|
|
border-bottom: 2px solid var(--primary) !important;
|
|
}
|
|
|
|
.change-badge {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 2px;
|
|
font-size: var(--font-size-xs);
|
|
padding: 2px 6px;
|
|
border-radius: var(--radius-sm);
|
|
}
|
|
|
|
.change-badge.positive {
|
|
background: #dcfce7;
|
|
color: #166534;
|
|
}
|
|
|
|
.change-badge.negative {
|
|
background: #fee2e2;
|
|
color: #991b1b;
|
|
}
|
|
|
|
.change-badge.neutral {
|
|
background: var(--bg-tertiary);
|
|
color: var(--text-tertiary);
|
|
}
|
|
|
|
.timeline-list {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: var(--spacing-sm);
|
|
}
|
|
|
|
.timeline-item {
|
|
background: var(--surface);
|
|
border-radius: var(--radius);
|
|
padding: var(--spacing-md);
|
|
box-shadow: var(--shadow-sm);
|
|
display: flex;
|
|
align-items: flex-start;
|
|
gap: var(--spacing-md);
|
|
}
|
|
|
|
.timeline-date {
|
|
font-size: var(--font-size-xs);
|
|
color: var(--text-tertiary);
|
|
min-width: 80px;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.timeline-content {
|
|
flex: 1;
|
|
font-size: var(--font-size-sm);
|
|
color: var(--text-secondary);
|
|
}
|
|
|
|
.timeline-content strong {
|
|
color: var(--text-primary);
|
|
}
|
|
|
|
.empty-state {
|
|
text-align: center;
|
|
padding: var(--spacing-2xl);
|
|
background: var(--surface);
|
|
border-radius: var(--radius-lg);
|
|
box-shadow: var(--shadow);
|
|
}
|
|
|
|
.empty-state svg {
|
|
width: 64px;
|
|
height: 64px;
|
|
color: var(--text-tertiary);
|
|
margin-bottom: var(--spacing-md);
|
|
}
|
|
|
|
.empty-state h3 {
|
|
color: var(--text-primary);
|
|
margin-bottom: var(--spacing-sm);
|
|
}
|
|
|
|
.empty-state p {
|
|
color: var(--text-secondary);
|
|
}
|
|
|
|
.btn {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: var(--spacing-xs);
|
|
padding: var(--spacing-sm) var(--spacing-md);
|
|
border-radius: var(--radius);
|
|
font-weight: 500;
|
|
font-size: var(--font-size-sm);
|
|
cursor: pointer;
|
|
transition: all 0.2s ease;
|
|
border: 1px solid transparent;
|
|
text-decoration: none;
|
|
}
|
|
|
|
.btn-primary {
|
|
background: var(--primary);
|
|
color: white;
|
|
border-color: var(--primary);
|
|
}
|
|
|
|
.btn-primary:hover {
|
|
opacity: 0.9;
|
|
}
|
|
|
|
.btn-outline {
|
|
background: transparent;
|
|
color: var(--text-secondary);
|
|
border-color: var(--border);
|
|
}
|
|
|
|
.btn-outline:hover {
|
|
background: var(--bg-tertiary);
|
|
}
|
|
|
|
.btn-sm {
|
|
padding: var(--spacing-xs) var(--spacing-sm);
|
|
font-size: var(--font-size-xs);
|
|
}
|
|
|
|
@media (max-width: 768px) {
|
|
.competitors-table {
|
|
display: block;
|
|
overflow-x: auto;
|
|
}
|
|
}
|
|
</style>
|
|
{% endblock %}
|
|
|
|
{% block content %}
|
|
<div class="dashboard-header">
|
|
<div>
|
|
{% if detail_view and company %}
|
|
<h1>Konkurenci: {{ company.name }}</h1>
|
|
<p>Porownanie z konkurentami z Google Maps</p>
|
|
{% else %}
|
|
<h1>Monitoring Konkurencji</h1>
|
|
<p>Sledzenie zmian u konkurentow firm czlonkowskich</p>
|
|
{% endif %}
|
|
</div>
|
|
<div>
|
|
{% if detail_view and company %}
|
|
<a href="{{ url_for('admin.admin_competitors') }}" class="btn btn-outline btn-sm">
|
|
<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="M10 19l-7-7m0 0l7-7m-7 7h18"/>
|
|
</svg>
|
|
Wszystkie firmy
|
|
</a>
|
|
{% endif %}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Stats -->
|
|
{% if not detail_view %}
|
|
<div class="stats-row">
|
|
<div class="stat-card">
|
|
<div class="stat-value">{{ total_companies }}</div>
|
|
<div class="stat-label">Firm z monitoringiem</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<div class="stat-value">{{ total_competitors }}</div>
|
|
<div class="stat-label">Sledzonych konkurentow</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<div class="stat-value">{{ recent_changes|length }}</div>
|
|
<div class="stat-label">Zmian w ostatnich 30 dniach</div>
|
|
</div>
|
|
</div>
|
|
{% endif %}
|
|
|
|
{% if detail_view and company %}
|
|
<!-- DETAIL VIEW: Single company vs competitors -->
|
|
|
|
{% if competitor_data %}
|
|
<!-- Comparison Table -->
|
|
<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: {{ company.name }} vs Konkurenci
|
|
</h2>
|
|
|
|
<div style="background: var(--surface); border-radius: var(--radius-lg); box-shadow: var(--shadow); overflow: hidden; margin-bottom: var(--spacing-xl);">
|
|
<table class="competitors-table">
|
|
<thead>
|
|
<tr>
|
|
<th>Firma</th>
|
|
<th>Ocena</th>
|
|
<th>Opinie</th>
|
|
<th>Zdjecia</th>
|
|
<th>Kategoria</th>
|
|
<th>Strona WWW</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<!-- Company's own data -->
|
|
<tr class="comparison-row">
|
|
<td>
|
|
<strong>{{ company.name }}</strong>
|
|
<span style="font-size: var(--font-size-xs); color: var(--primary); margin-left: 4px;">(Twoja firma)</span>
|
|
</td>
|
|
<td>
|
|
{% if gbp_audit and gbp_audit.average_rating %}
|
|
<span class="rating-stars">{{ gbp_audit.average_rating }}/5</span>
|
|
{% else %}
|
|
<span style="color: var(--text-tertiary);">-</span>
|
|
{% endif %}
|
|
</td>
|
|
<td>{{ gbp_audit.review_count if gbp_audit and gbp_audit.review_count else '-' }}</td>
|
|
<td>{{ gbp_audit.photo_count if gbp_audit and gbp_audit.photo_count else '-' }}</td>
|
|
<td style="font-size: var(--font-size-xs);">{{ company.category.name if company.category else '-' }}</td>
|
|
<td style="font-size: var(--font-size-xs);">{{ 'Tak' if company.website else 'Brak' }}</td>
|
|
</tr>
|
|
<!-- Competitors -->
|
|
{% for item in competitor_data %}
|
|
{% set comp = item.competitor %}
|
|
{% set snap = item.latest_snapshot %}
|
|
<tr>
|
|
<td>
|
|
{{ comp.competitor_name or 'Bez nazwy' }}
|
|
{% if comp.added_by == 'manual' %}
|
|
<span style="font-size: 9px; padding: 1px 4px; background: #dbeafe; color: #1e40af; border-radius: 2px; margin-left: 4px;">RECZNY</span>
|
|
{% endif %}
|
|
</td>
|
|
<td>
|
|
{% if comp.competitor_rating %}
|
|
<span class="rating-stars">{{ comp.competitor_rating }}/5</span>
|
|
{% if snap and snap.changes and snap.changes.get('rating_change') %}
|
|
{% set rc = snap.changes.rating_change %}
|
|
<span class="change-badge {{ 'positive' if rc > 0 else 'negative' }}">
|
|
{{ '+' if rc > 0 else '' }}{{ rc }}
|
|
</span>
|
|
{% endif %}
|
|
{% else %}
|
|
<span style="color: var(--text-tertiary);">-</span>
|
|
{% endif %}
|
|
</td>
|
|
<td>
|
|
{{ comp.competitor_review_count or '-' }}
|
|
{% if snap and snap.changes and snap.changes.get('new_reviews') %}
|
|
<span class="change-badge positive">+{{ snap.changes.new_reviews }}</span>
|
|
{% endif %}
|
|
</td>
|
|
<td>{{ snap.photo_count if snap else '-' }}</td>
|
|
<td style="font-size: var(--font-size-xs);">{{ comp.competitor_category or '-' }}</td>
|
|
<td style="font-size: var(--font-size-xs);">
|
|
{% if comp.competitor_website %}
|
|
<a href="{{ comp.competitor_website }}" target="_blank" rel="noopener" style="color: var(--primary);">Link</a>
|
|
{% else %}
|
|
Brak
|
|
{% endif %}
|
|
</td>
|
|
</tr>
|
|
{% endfor %}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<!-- Timeline of Changes -->
|
|
{% if timeline %}
|
|
<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="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
|
</svg>
|
|
Ostatnie zmiany
|
|
</h2>
|
|
|
|
<div class="timeline-list">
|
|
{% for snap in timeline %}
|
|
<div class="timeline-item">
|
|
<div class="timeline-date">{{ snap.snapshot_date.strftime('%d.%m') }}</div>
|
|
<div class="timeline-content">
|
|
<strong>{{ snap.competitor.competitor_name }}</strong>
|
|
{% if snap.changes %}
|
|
{% if snap.changes.get('new_reviews') %}
|
|
— {{ snap.changes.new_reviews }} nowych opinii
|
|
{% endif %}
|
|
{% if snap.changes.get('rating_change') %}
|
|
— ocena {{ '+' if snap.changes.rating_change > 0 else '' }}{{ snap.changes.rating_change }}
|
|
{% endif %}
|
|
{% if snap.changes.get('new_photos') %}
|
|
— {{ snap.changes.new_photos }} nowych zdjec
|
|
{% endif %}
|
|
{% else %}
|
|
— brak zmian
|
|
{% endif %}
|
|
</div>
|
|
</div>
|
|
{% endfor %}
|
|
</div>
|
|
{% endif %}
|
|
|
|
{% else %}
|
|
<!-- No competitors -->
|
|
<div class="empty-state">
|
|
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"/>
|
|
</svg>
|
|
<h3>Brak sledzonych konkurentow</h3>
|
|
<p>Dla firmy {{ company.name }} nie dodano jeszcze konkurentow. Uruchom skrypt discovery, aby automatycznie znalezc konkurentow w okolicy.</p>
|
|
</div>
|
|
{% endif %}
|
|
|
|
{% else %}
|
|
<!-- OVERVIEW: All companies with competitors -->
|
|
|
|
{% if company_data %}
|
|
{% for item in company_data %}
|
|
<div class="company-competitor-card">
|
|
<div class="company-competitor-header">
|
|
<h3>
|
|
<a href="{{ url_for('admin.admin_competitor_detail', company_id=item.company.id) }}" style="color: var(--text-primary); text-decoration: none;">
|
|
{{ item.company.name }}
|
|
</a>
|
|
</h3>
|
|
<div style="display: flex; align-items: center; gap: var(--spacing-sm);">
|
|
<span class="competitor-count-badge">{{ item.competitor_count }} konkurentow</span>
|
|
<a href="{{ url_for('admin.admin_competitor_detail', company_id=item.company.id) }}" class="btn btn-outline btn-sm">Szczegoly</a>
|
|
</div>
|
|
</div>
|
|
{% if item.competitors %}
|
|
<table class="competitors-table">
|
|
<thead>
|
|
<tr>
|
|
<th>Konkurent</th>
|
|
<th>Ocena</th>
|
|
<th>Opinie</th>
|
|
<th>Kategoria</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{% for comp in item.competitors[:5] %}
|
|
<tr>
|
|
<td>{{ comp.competitor_name or 'Bez nazwy' }}</td>
|
|
<td><span class="rating-stars">{{ comp.competitor_rating or '-' }}{% if comp.competitor_rating %}/5{% endif %}</span></td>
|
|
<td>{{ comp.competitor_review_count or '-' }}</td>
|
|
<td style="font-size: var(--font-size-xs);">{{ comp.competitor_category or '-' }}</td>
|
|
</tr>
|
|
{% endfor %}
|
|
{% if item.competitors|length > 5 %}
|
|
<tr>
|
|
<td colspan="4" style="text-align: center; color: var(--text-tertiary); font-style: italic;">
|
|
...i {{ item.competitors|length - 5 }} wiecej
|
|
</td>
|
|
</tr>
|
|
{% endif %}
|
|
</tbody>
|
|
</table>
|
|
{% endif %}
|
|
</div>
|
|
{% endfor %}
|
|
|
|
{% else %}
|
|
<!-- No companies with competitors -->
|
|
<div class="empty-state">
|
|
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"/>
|
|
</svg>
|
|
<h3>Brak monitorowanych konkurentow</h3>
|
|
<p>Zaden z firm czlonkowskich nie ma jeszcze sledzonych konkurentow. Uruchom skrypt <code>scripts/competitor_monitor_cron.py --discover</code>, aby automatycznie znalezc konkurentow dla firm z profilem Google Business.</p>
|
|
</div>
|
|
{% endif %}
|
|
|
|
<!-- Recent Changes -->
|
|
{% if recent_changes %}
|
|
<h2 class="section-title" style="margin-top: var(--spacing-xl);">
|
|
<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="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
|
</svg>
|
|
Ostatnie zmiany (30 dni)
|
|
</h2>
|
|
|
|
<div class="timeline-list">
|
|
{% for snap in recent_changes %}
|
|
<div class="timeline-item">
|
|
<div class="timeline-date">{{ snap.snapshot_date.strftime('%d.%m.%Y') }}</div>
|
|
<div class="timeline-content">
|
|
<strong>{{ snap.competitor.competitor_name }}</strong>
|
|
{% if snap.changes %}
|
|
{% if snap.changes.get('new_reviews') %}
|
|
— {{ snap.changes.new_reviews }} nowych opinii
|
|
{% endif %}
|
|
{% if snap.changes.get('rating_change') %}
|
|
— ocena {{ '+' if snap.changes.rating_change > 0 else '' }}{{ snap.changes.rating_change }}
|
|
{% endif %}
|
|
{% if snap.changes.get('new_photos') %}
|
|
— {{ snap.changes.new_photos }} nowych zdjec
|
|
{% endif %}
|
|
{% endif %}
|
|
</div>
|
|
</div>
|
|
{% endfor %}
|
|
</div>
|
|
{% endif %}
|
|
|
|
{% endif %}
|
|
{% endblock %}
|