feat: Rozbudowa systemu analityki użytkowników

Nowe funkcjonalności:
- GeoIP enrichment (kraj, miasto, region)
- UTM parameters tracking (source, medium, campaign, term, content)
- Bounce rate calculation
- Search queries logging
- Conversion tracking (register, login, contact_click, rsvp)
- Scroll depth tracking (25%, 50%, 75%, 100%)
- JS error tracking (window.onerror)
- Performance metrics (Web Vitals)
- CSV export (sessions, pageviews, searches, conversions)

Nowe tabele SQL:
- search_queries
- conversion_events
- js_errors
- popular_searches_daily
- hourly_activity

Dashboard rozszerzony o nowe sekcje i metryki.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Maciej Pienczyn 2026-01-30 15:52:18 +01:00
parent 64583b6ec4
commit 0055857df4
7 changed files with 1387 additions and 6 deletions

475
app.py
View File

@ -144,6 +144,11 @@ from database import (
UserClick,
AnalyticsDaily,
PopularPagesDaily,
SearchQuery,
ConversionEvent,
JSError,
PopularSearchesDaily,
HourlyActivity,
AuditLog,
SecurityAlert,
ZOPKNews
@ -420,6 +425,7 @@ def get_or_create_analytics_session():
"""
Get existing analytics session or create new one.
Returns the database session ID (integer).
Includes GeoIP lookup and UTM parameter parsing.
"""
analytics_session_id = session.get('analytics_session_id')
@ -448,16 +454,48 @@ def get_or_create_analytics_session():
os_name = 'Unknown'
os_version = ''
# GeoIP lookup
country, city, region = None, None, None
ip_address = request.headers.get('X-Forwarded-For', request.remote_addr)
if ip_address:
ip_address = ip_address.split(',')[0].strip()
try:
from security_service import get_geoip_info
geo_info = get_geoip_info(ip_address)
if geo_info:
country = geo_info.get('country')
city = geo_info.get('city')
region = geo_info.get('region')
except Exception as e:
logger.debug(f"GeoIP lookup failed for {ip_address}: {e}")
# UTM parameters (z pierwszego requestu sesji)
utm_source = request.args.get('utm_source', '')[:255] or None
utm_medium = request.args.get('utm_medium', '')[:255] or None
utm_campaign = request.args.get('utm_campaign', '')[:255] or None
utm_term = request.args.get('utm_term', '')[:255] or None
utm_content = request.args.get('utm_content', '')[:255] or None
user_session = UserSession(
session_id=analytics_session_id,
user_id=current_user.id if current_user.is_authenticated else None,
ip_address=request.remote_addr,
ip_address=ip_address,
user_agent=ua_string[:2000] if ua_string else None,
device_type=device_type,
browser=browser[:50] if browser else None,
browser_version=browser_version[:20] if browser_version else None,
os=os_name[:50] if os_name else None,
os_version=os_version[:20] if os_version else None
os_version=os_version[:20] if os_version else None,
# GeoIP
country=country,
city=city,
region=region,
# UTM
utm_source=utm_source,
utm_medium=utm_medium,
utm_campaign=utm_campaign,
utm_term=utm_term,
utm_content=utm_content
)
db.add(user_session)
db.commit()
@ -478,6 +516,63 @@ def get_or_create_analytics_session():
db.close()
def track_conversion(event_type: str, company_id: int = None, target_type: str = None,
target_value: str = None, metadata: dict = None):
"""
Track conversion event.
Args:
event_type: Type of conversion (register, login, contact_click, rsvp, message, classified)
company_id: Related company ID (for contact_click)
target_type: What was clicked (email, phone, website)
target_value: The value (email address, phone number, etc.)
metadata: Additional data as dict
"""
try:
analytics_session_id = session.get('analytics_session_id')
session_db_id = None
db = SessionLocal()
try:
if analytics_session_id:
user_session = db.query(UserSession).filter_by(session_id=analytics_session_id).first()
if user_session:
session_db_id = user_session.id
# Określ kategorię konwersji
category_map = {
'register': 'acquisition',
'login': 'activation',
'contact_click': 'engagement',
'rsvp': 'engagement',
'message': 'engagement',
'classified': 'engagement'
}
conversion = ConversionEvent(
session_id=session_db_id,
user_id=current_user.id if current_user.is_authenticated else None,
event_type=event_type,
event_category=category_map.get(event_type, 'other'),
company_id=company_id,
target_type=target_type,
target_value=target_value[:500] if target_value else None,
source_page=request.url[:500] if request.url else None,
referrer=request.referrer[:500] if request.referrer else None,
event_metadata=metadata
)
db.add(conversion)
db.commit()
logger.info(f"Conversion tracked: {event_type} company={company_id} target={target_type}")
except Exception as e:
logger.error(f"Conversion tracking error: {e}")
db.rollback()
finally:
db.close()
except Exception as e:
logger.error(f"Conversion tracking outer error: {e}")
@app.before_request
def check_geoip():
"""Block requests from high-risk countries (RU, CN, KP, IR, BY, SY, VE, CU)."""
@ -1131,6 +1226,7 @@ def company_detail(company_id):
return render_template('company_detail.html',
company=company,
company_id=company.id, # For analytics conversion tracking
maturity_data=maturity_data,
website_analysis=website_analysis,
quality_data=quality_data,
@ -1293,6 +1389,32 @@ def search():
# Extract companies from SearchResult objects
companies = [r.company for r in results]
# Log search to analytics (SearchQuery table)
if query:
try:
analytics_session_id = session.get('analytics_session_id')
session_db_id = None
if analytics_session_id:
user_session = db.query(UserSession).filter_by(session_id=analytics_session_id).first()
if user_session:
session_db_id = user_session.id
search_query = SearchQuery(
session_id=session_db_id,
user_id=current_user.id if current_user.is_authenticated else None,
query=query[:500],
query_normalized=query.lower().strip()[:500],
results_count=len(companies),
has_results=len(companies) > 0,
search_type='main',
filters_used={'category_id': category_id} if category_id else None
)
db.add(search_query)
db.commit()
except Exception as e:
logger.error(f"Search logging error: {e}")
db.rollback()
# For debugging/analytics - log search stats
if query:
match_types = {}
@ -3372,6 +3494,147 @@ def api_analytics_heartbeat():
db.close()
@app.route('/api/analytics/scroll', methods=['POST'])
@csrf.exempt
def api_analytics_scroll():
"""Track scroll depth from frontend"""
data = request.get_json()
if not data:
return jsonify({'error': 'No data'}), 400
page_view_id = data.get('page_view_id')
scroll_depth = data.get('scroll_depth')
if not page_view_id or scroll_depth is None:
return jsonify({'error': 'Missing data'}), 400
db = SessionLocal()
try:
page_view = db.query(PageView).filter_by(id=page_view_id).first()
if page_view:
# Zapisz tylko jeśli większe niż poprzednie (max scroll depth)
current_depth = page_view.scroll_depth_percent or 0
if scroll_depth > current_depth:
page_view.scroll_depth_percent = min(scroll_depth, 100)
db.commit()
return jsonify({'success': True}), 200
except Exception as e:
logger.error(f"Analytics scroll error: {e}")
db.rollback()
return jsonify({'success': False}), 200
finally:
db.close()
@app.route('/api/analytics/error', methods=['POST'])
@csrf.exempt
def api_analytics_error():
"""Track JavaScript errors from frontend"""
import hashlib
data = request.get_json()
if not data:
return jsonify({'error': 'No data'}), 400
message = data.get('message', '')[:2000]
if not message:
return jsonify({'error': 'No message'}), 400
analytics_session_id = session.get('analytics_session_id')
db = SessionLocal()
try:
# Znajdź session ID
session_db_id = None
if analytics_session_id:
user_session = db.query(UserSession).filter_by(session_id=analytics_session_id).first()
if user_session:
session_db_id = user_session.id
# Utwórz hash dla agregacji
error_key = f"{message}|{data.get('source', '')}|{data.get('lineno', '')}"
error_hash = hashlib.sha256(error_key.encode()).hexdigest()
js_error = JSError(
session_id=session_db_id,
message=message,
source=data.get('source', '')[:500] if data.get('source') else None,
lineno=data.get('lineno'),
colno=data.get('colno'),
stack=data.get('stack', '')[:5000] if data.get('stack') else None,
url=data.get('url', '')[:2000] if data.get('url') else None,
user_agent=request.headers.get('User-Agent', '')[:500],
error_hash=error_hash
)
db.add(js_error)
db.commit()
return jsonify({'success': True}), 200
except Exception as e:
logger.error(f"Analytics error tracking error: {e}")
db.rollback()
return jsonify({'success': False}), 200
finally:
db.close()
@app.route('/api/analytics/performance', methods=['POST'])
@csrf.exempt
def api_analytics_performance():
"""Track page performance metrics from frontend"""
data = request.get_json()
if not data:
return jsonify({'error': 'No data'}), 400
page_view_id = data.get('page_view_id')
if not page_view_id:
return jsonify({'error': 'Missing page_view_id'}), 400
db = SessionLocal()
try:
page_view = db.query(PageView).filter_by(id=page_view_id).first()
if page_view:
# Zapisz metryki performance (tylko jeśli jeszcze nie zapisane)
if page_view.dom_content_loaded_ms is None:
page_view.dom_content_loaded_ms = data.get('dom_content_loaded_ms')
if page_view.load_time_ms is None:
page_view.load_time_ms = data.get('load_time_ms')
if page_view.first_paint_ms is None:
page_view.first_paint_ms = data.get('first_paint_ms')
if page_view.first_contentful_paint_ms is None:
page_view.first_contentful_paint_ms = data.get('first_contentful_paint_ms')
db.commit()
return jsonify({'success': True}), 200
except Exception as e:
logger.error(f"Analytics performance error: {e}")
db.rollback()
return jsonify({'success': False}), 200
finally:
db.close()
@app.route('/api/analytics/conversion', methods=['POST'])
@csrf.exempt
def api_analytics_conversion():
"""Track conversion events from frontend (contact clicks)"""
data = request.get_json()
if not data:
return jsonify({'error': 'No data'}), 400
event_type = data.get('event_type')
if not event_type:
return jsonify({'error': 'Missing event_type'}), 400
track_conversion(
event_type=event_type,
company_id=data.get('company_id'),
target_type=data.get('target_type'),
target_value=data.get('target_value'),
metadata=data.get('metadata')
)
return jsonify({'success': True}), 200
# ============================================================
# RECOMMENDATIONS API ROUTES
# ============================================================
@ -7555,6 +7818,94 @@ def admin_analytics():
'pages': user_pages
}
# ============================================================
# NOWE METRYKI (Analytics Expansion 2026-01-30)
# ============================================================
# Bounce rate: sesje z 1 pageview LUB czas < 10s
bounced_sessions = sessions_query.filter(
(UserSession.page_views_count <= 1) |
((UserSession.duration_seconds.isnot(None)) & (UserSession.duration_seconds < 10))
).count()
bounce_rate = round((bounced_sessions / total_sessions * 100), 1) if total_sessions > 0 else 0
# Geolokalizacja - top 10 krajów
country_query = db.query(
UserSession.country,
func.count(UserSession.id).label('count')
).filter(UserSession.country.isnot(None))
if start_date:
country_query = country_query.filter(func.date(UserSession.started_at) >= start_date)
country_stats = dict(country_query.group_by(UserSession.country).order_by(desc('count')).limit(10).all())
# UTM sources
utm_query = db.query(
UserSession.utm_source,
func.count(UserSession.id).label('count')
).filter(UserSession.utm_source.isnot(None))
if start_date:
utm_query = utm_query.filter(func.date(UserSession.started_at) >= start_date)
utm_stats = dict(utm_query.group_by(UserSession.utm_source).order_by(desc('count')).limit(10).all())
# Top wyszukiwania
search_query = db.query(
SearchQuery.query_normalized,
func.count(SearchQuery.id).label('count'),
func.avg(SearchQuery.results_count).label('avg_results')
)
if start_date:
search_query = search_query.filter(func.date(SearchQuery.searched_at) >= start_date)
top_searches = search_query.group_by(SearchQuery.query_normalized).order_by(desc('count')).limit(15).all()
# Wyszukiwania bez wyników
no_results_query = db.query(
SearchQuery.query_normalized,
func.count(SearchQuery.id).label('count')
).filter(SearchQuery.has_results == False)
if start_date:
no_results_query = no_results_query.filter(func.date(SearchQuery.searched_at) >= start_date)
searches_no_results = no_results_query.group_by(SearchQuery.query_normalized).order_by(desc('count')).limit(10).all()
# Konwersje
conversion_query = db.query(
ConversionEvent.event_type,
func.count(ConversionEvent.id).label('count')
)
if start_date:
conversion_query = conversion_query.filter(func.date(ConversionEvent.converted_at) >= start_date)
conversion_stats = dict(conversion_query.group_by(ConversionEvent.event_type).all())
# Błędy JS (agregowane)
error_query = db.query(
JSError.message,
JSError.source,
func.count(JSError.id).label('count')
)
if start_date:
error_query = error_query.filter(func.date(JSError.occurred_at) >= start_date)
js_errors = error_query.group_by(JSError.error_hash, JSError.message, JSError.source).order_by(desc('count')).limit(10).all()
# Średni scroll depth
avg_scroll = db.query(func.avg(PageView.scroll_depth_percent)).filter(
PageView.scroll_depth_percent.isnot(None)
)
if start_date:
avg_scroll = avg_scroll.filter(func.date(PageView.viewed_at) >= start_date)
avg_scroll_depth = round(avg_scroll.scalar() or 0, 1)
# Wzorce czasowe - aktywność wg godziny
hourly_query = db.query(
func.extract('hour', UserSession.started_at).label('hour'),
func.count(UserSession.id).label('count')
)
if start_date:
hourly_query = hourly_query.filter(func.date(UserSession.started_at) >= start_date)
hourly_activity = dict(hourly_query.group_by('hour').all())
# Dodaj nowe statystyki do stats
stats['bounce_rate'] = bounce_rate
stats['avg_scroll_depth'] = avg_scroll_depth
return render_template(
'admin/analytics_dashboard.html',
stats=stats,
@ -7563,7 +7914,15 @@ def admin_analytics():
popular_pages=popular_pages,
recent_sessions=recent_sessions,
user_detail=user_detail,
current_period=period
current_period=period,
# Nowe dane
country_stats=country_stats,
utm_stats=utm_stats,
top_searches=top_searches,
searches_no_results=searches_no_results,
conversion_stats=conversion_stats,
js_errors=js_errors,
hourly_activity=hourly_activity
)
except Exception as e:
logger.error(f"Admin analytics error: {e}")
@ -7573,6 +7932,113 @@ def admin_analytics():
db.close()
@app.route('/admin/analytics/export')
@login_required
def admin_analytics_export():
"""Export analytics data as CSV"""
import csv
import io
if not current_user.is_admin:
flash('Brak uprawnien.', 'error')
return redirect(url_for('dashboard'))
export_type = request.args.get('type', 'sessions')
period = request.args.get('period', 'month')
from datetime import date, timedelta
today = date.today()
if period == 'day':
start_date = today
elif period == 'week':
start_date = today - timedelta(days=7)
elif period == 'month':
start_date = today - timedelta(days=30)
else:
start_date = today - timedelta(days=365) # year
db = SessionLocal()
try:
output = io.StringIO()
writer = csv.writer(output)
if export_type == 'sessions':
writer.writerow(['ID', 'User ID', 'Started At', 'Duration (s)', 'Page Views', 'Clicks',
'Device', 'Browser', 'OS', 'Country', 'UTM Source', 'UTM Campaign'])
sessions = db.query(UserSession).filter(
func.date(UserSession.started_at) >= start_date
).order_by(UserSession.started_at.desc()).all()
for s in sessions:
writer.writerow([
s.id, s.user_id, s.started_at.isoformat() if s.started_at else '',
s.duration_seconds or 0, s.page_views_count or 0, s.clicks_count or 0,
s.device_type or '', s.browser or '', s.os or '',
s.country or '', s.utm_source or '', s.utm_campaign or ''
])
elif export_type == 'pageviews':
writer.writerow(['ID', 'Session ID', 'User ID', 'Path', 'Viewed At', 'Time on Page (s)',
'Scroll Depth (%)', 'Company ID'])
views = db.query(PageView).filter(
func.date(PageView.viewed_at) >= start_date
).order_by(PageView.viewed_at.desc()).limit(10000).all()
for v in views:
writer.writerow([
v.id, v.session_id, v.user_id, v.path,
v.viewed_at.isoformat() if v.viewed_at else '',
v.time_on_page_seconds or 0, v.scroll_depth_percent or 0, v.company_id or ''
])
elif export_type == 'searches':
writer.writerow(['ID', 'User ID', 'Query', 'Results Count', 'Has Results', 'Clicked Company',
'Search Type', 'Searched At'])
searches = db.query(SearchQuery).filter(
func.date(SearchQuery.searched_at) >= start_date
).order_by(SearchQuery.searched_at.desc()).limit(10000).all()
for s in searches:
writer.writerow([
s.id, s.user_id, s.query, s.results_count, s.has_results,
s.clicked_company_id or '', s.search_type,
s.searched_at.isoformat() if s.searched_at else ''
])
elif export_type == 'conversions':
writer.writerow(['ID', 'User ID', 'Event Type', 'Event Category', 'Company ID',
'Target Type', 'Converted At'])
conversions = db.query(ConversionEvent).filter(
func.date(ConversionEvent.converted_at) >= start_date
).order_by(ConversionEvent.converted_at.desc()).all()
for c in conversions:
writer.writerow([
c.id, c.user_id, c.event_type, c.event_category or '',
c.company_id or '', c.target_type or '',
c.converted_at.isoformat() if c.converted_at else ''
])
output.seek(0)
return Response(
output.getvalue(),
mimetype='text/csv',
headers={'Content-Disposition': f'attachment; filename=analytics_{export_type}_{period}.csv'}
)
except Exception as e:
logger.error(f"Export error: {e}")
flash('Blad podczas eksportu.', 'error')
return redirect(url_for('admin_analytics'))
finally:
db.close()
@app.route('/api/admin/ai-learning-status')
@login_required
def api_ai_learning_status():
@ -14888,7 +15354,8 @@ def honeypot_trap(path=None):
# ============================================================
if __name__ == '__main__':
port = int(os.getenv('PORT', 5000))
# Port 5001 jako domyślny - macOS AirPlay zajmuje 5000
port = int(os.getenv('PORT', 5001))
debug = os.getenv('FLASK_ENV') == 'development'
logger.info(f"Starting Norda Biznes Partner on port {port}")

View File

@ -2765,6 +2765,13 @@ class UserSession(Base):
page_views_count = Column(Integer, default=0)
clicks_count = Column(Integer, default=0)
# UTM Parameters (kampanie marketingowe)
utm_source = Column(String(255), nullable=True) # google, facebook, newsletter
utm_medium = Column(String(255), nullable=True) # cpc, email, social, organic
utm_campaign = Column(String(255), nullable=True) # nazwa kampanii
utm_term = Column(String(255), nullable=True) # słowo kluczowe (PPC)
utm_content = Column(String(255), nullable=True) # wariant reklamy
created_at = Column(DateTime, default=datetime.now)
# Relationships
@ -2796,6 +2803,15 @@ class PageView(Base):
viewed_at = Column(DateTime, nullable=False, default=datetime.now, index=True)
time_on_page_seconds = Column(Integer, nullable=True)
# Scroll depth (%)
scroll_depth_percent = Column(Integer, nullable=True) # 0-100
# Performance metrics (Web Vitals)
dom_content_loaded_ms = Column(Integer, nullable=True)
load_time_ms = Column(Integer, nullable=True)
first_paint_ms = Column(Integer, nullable=True)
first_contentful_paint_ms = Column(Integer, nullable=True)
# Kontekst
company_id = Column(Integer, ForeignKey('companies.id', ondelete='SET NULL'), nullable=True)
@ -2872,6 +2888,17 @@ class AnalyticsDaily(Base):
# Engagement
bounce_rate = Column(Numeric(5, 2), nullable=True)
# Nowe metryki (Analytics Expansion 2026-01-30)
conversions_count = Column(Integer, default=0)
searches_count = Column(Integer, default=0)
searches_no_results = Column(Integer, default=0)
avg_scroll_depth = Column(Numeric(5, 2), nullable=True)
js_errors_count = Column(Integer, default=0)
# Rozkłady (JSONB)
utm_breakdown = Column(JSONBType, nullable=True) # {"google": 10, "facebook": 5}
conversions_breakdown = Column(JSONBType, nullable=True) # {"register": 2, "contact_click": 15}
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
def __repr__(self):
@ -2901,6 +2928,160 @@ class PopularPagesDaily(Base):
return f"<PopularPagesDaily {self.date} {self.path}>"
class SearchQuery(Base):
"""
Historia wyszukiwań użytkowników w portalu.
Śledzi zapytania, wyniki i interakcje.
Created: 2026-01-30
"""
__tablename__ = 'search_queries'
id = Column(Integer, primary_key=True)
session_id = Column(Integer, ForeignKey('user_sessions.id', ondelete='SET NULL'), nullable=True)
user_id = Column(Integer, ForeignKey('users.id', ondelete='SET NULL'), nullable=True)
# Zapytanie
query = Column(String(500), nullable=False)
query_normalized = Column(String(500), nullable=True) # lowercase, bez znaków specjalnych
# Wyniki
results_count = Column(Integer, default=0)
has_results = Column(Boolean, default=True)
# Interakcja z wynikami
clicked_result_position = Column(Integer, nullable=True) # 1-based
clicked_company_id = Column(Integer, ForeignKey('companies.id', ondelete='SET NULL'), nullable=True)
# Kontekst
search_type = Column(String(50), default='main') # main, chat, autocomplete
filters_used = Column(JSONBType, nullable=True) # {"category": "IT", "city": "Wejherowo"}
# Timing
searched_at = Column(DateTime, nullable=False, default=datetime.now)
time_to_click_ms = Column(Integer, nullable=True)
created_at = Column(DateTime, default=datetime.now)
def __repr__(self):
return f"<SearchQuery '{self.query[:30]}...' results={self.results_count}>"
class ConversionEvent(Base):
"""
Kluczowe konwersje: rejestracje, kontakty z firmami, RSVP na wydarzenia.
Created: 2026-01-30
"""
__tablename__ = 'conversion_events'
id = Column(Integer, primary_key=True)
session_id = Column(Integer, ForeignKey('user_sessions.id', ondelete='SET NULL'), nullable=True)
user_id = Column(Integer, ForeignKey('users.id', ondelete='SET NULL'), nullable=True)
# Typ konwersji
event_type = Column(String(50), nullable=False) # register, login, contact_click, rsvp, message, classified
event_category = Column(String(50), nullable=True) # engagement, acquisition, activation
# Kontekst
company_id = Column(Integer, ForeignKey('companies.id', ondelete='SET NULL'), nullable=True)
target_type = Column(String(50), nullable=True) # email, phone, website, rsvp_event
target_value = Column(String(500), nullable=True)
# Źródło konwersji
source_page = Column(String(500), nullable=True)
referrer = Column(String(500), nullable=True)
# Dodatkowe dane
event_metadata = Column(JSONBType, nullable=True)
# Timing
converted_at = Column(DateTime, nullable=False, default=datetime.now)
created_at = Column(DateTime, default=datetime.now)
# Relationships
company = relationship('Company', backref='conversion_events')
def __repr__(self):
return f"<ConversionEvent {self.event_type} at {self.converted_at}>"
class JSError(Base):
"""
Błędy JavaScript zgłaszane z przeglądarek użytkowników.
Created: 2026-01-30
"""
__tablename__ = 'js_errors'
id = Column(Integer, primary_key=True)
session_id = Column(Integer, ForeignKey('user_sessions.id', ondelete='SET NULL'), nullable=True)
# Błąd
message = Column(Text, nullable=False)
source = Column(String(500), nullable=True) # URL pliku JS
lineno = Column(Integer, nullable=True)
colno = Column(Integer, nullable=True)
stack = Column(Text, nullable=True)
# Kontekst
url = Column(String(2000), nullable=True)
user_agent = Column(String(500), nullable=True)
# Agregacja
error_hash = Column(String(64), nullable=True) # SHA256 dla grupowania
occurred_at = Column(DateTime, nullable=False, default=datetime.now)
created_at = Column(DateTime, default=datetime.now)
def __repr__(self):
return f"<JSError '{self.message[:50]}...'>"
class PopularSearchesDaily(Base):
"""
Popularne wyszukiwania - dzienne agregaty.
Created: 2026-01-30
"""
__tablename__ = 'popular_searches_daily'
id = Column(Integer, primary_key=True)
date = Column(Date, nullable=False, index=True)
query_normalized = Column(String(500), nullable=False)
search_count = Column(Integer, default=0)
unique_users = Column(Integer, default=0)
click_count = Column(Integer, default=0)
avg_results_count = Column(Numeric(10, 2), nullable=True)
__table_args__ = (
UniqueConstraint('date', 'query_normalized', name='uq_popular_searches_date_query'),
)
def __repr__(self):
return f"<PopularSearchesDaily {self.date} '{self.query_normalized}'>"
class HourlyActivity(Base):
"""
Aktywność wg godziny - dla analizy wzorców czasowych.
Created: 2026-01-30
"""
__tablename__ = 'hourly_activity'
id = Column(Integer, primary_key=True)
date = Column(Date, nullable=False, index=True)
hour = Column(Integer, nullable=False) # 0-23
sessions_count = Column(Integer, default=0)
page_views_count = Column(Integer, default=0)
unique_users = Column(Integer, default=0)
__table_args__ = (
UniqueConstraint('date', 'hour', name='uq_hourly_activity_date_hour'),
)
def __repr__(self):
return f"<HourlyActivity {self.date} {self.hour}:00>"
# ============================================================
# EMAIL LOGGING
# ============================================================

View File

@ -0,0 +1,264 @@
-- ============================================================
-- Migration: 033_analytics_expansion.sql
-- Description: Rozbudowa systemu analityki użytkowników
-- Author: Claude
-- Date: 2026-01-30
-- ============================================================
BEGIN;
-- ============================================================
-- 1. UTM PARAMETERS - Dodanie do user_sessions
-- ============================================================
ALTER TABLE user_sessions ADD COLUMN IF NOT EXISTS utm_source VARCHAR(255);
ALTER TABLE user_sessions ADD COLUMN IF NOT EXISTS utm_medium VARCHAR(255);
ALTER TABLE user_sessions ADD COLUMN IF NOT EXISTS utm_campaign VARCHAR(255);
ALTER TABLE user_sessions ADD COLUMN IF NOT EXISTS utm_term VARCHAR(255);
ALTER TABLE user_sessions ADD COLUMN IF NOT EXISTS utm_content VARCHAR(255);
-- Indeks dla raportowania kampanii
CREATE INDEX IF NOT EXISTS idx_user_sessions_utm_source ON user_sessions(utm_source) WHERE utm_source IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_user_sessions_utm_campaign ON user_sessions(utm_campaign) WHERE utm_campaign IS NOT NULL;
COMMENT ON COLUMN user_sessions.utm_source IS 'Źródło ruchu: google, facebook, newsletter';
COMMENT ON COLUMN user_sessions.utm_medium IS 'Medium: cpc, email, social, organic';
COMMENT ON COLUMN user_sessions.utm_campaign IS 'Nazwa kampanii marketingowej';
COMMENT ON COLUMN user_sessions.utm_term IS 'Słowo kluczowe (dla reklam PPC)';
COMMENT ON COLUMN user_sessions.utm_content IS 'Wariant reklamy/linku';
-- ============================================================
-- 2. SCROLL DEPTH - Dodanie do page_views
-- ============================================================
ALTER TABLE page_views ADD COLUMN IF NOT EXISTS scroll_depth_percent INTEGER;
COMMENT ON COLUMN page_views.scroll_depth_percent IS 'Maksymalny % przewinięcia strony (0-100)';
-- ============================================================
-- 3. SEARCH QUERIES - Nowa tabela
-- ============================================================
CREATE TABLE IF NOT EXISTS search_queries (
id SERIAL PRIMARY KEY,
session_id INTEGER REFERENCES user_sessions(id) ON DELETE SET NULL,
user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
-- Zapytanie
query VARCHAR(500) NOT NULL,
query_normalized VARCHAR(500), -- lowercase, bez znaków specjalnych
-- Wyniki
results_count INTEGER DEFAULT 0,
has_results BOOLEAN DEFAULT TRUE,
-- Interakcja z wynikami
clicked_result_position INTEGER, -- Która pozycja była kliknięta (1-based)
clicked_company_id INTEGER REFERENCES companies(id) ON DELETE SET NULL,
-- Kontekst
search_type VARCHAR(50) DEFAULT 'main', -- main, chat, autocomplete
filters_used JSONB, -- {"category": "IT", "city": "Wejherowo"}
-- Timing
searched_at TIMESTAMP NOT NULL DEFAULT NOW(),
time_to_click_ms INTEGER, -- Czas od wyświetlenia do kliknięcia
created_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_search_queries_query ON search_queries(query_normalized);
CREATE INDEX IF NOT EXISTS idx_search_queries_searched_at ON search_queries(searched_at);
CREATE INDEX IF NOT EXISTS idx_search_queries_session ON search_queries(session_id);
CREATE INDEX IF NOT EXISTS idx_search_queries_has_results ON search_queries(has_results) WHERE has_results = FALSE;
COMMENT ON TABLE search_queries IS 'Historia wyszukiwań użytkowników w portalu';
-- ============================================================
-- 4. CONVERSION EVENTS - Nowa tabela
-- ============================================================
CREATE TABLE IF NOT EXISTS conversion_events (
id SERIAL PRIMARY KEY,
session_id INTEGER REFERENCES user_sessions(id) ON DELETE SET NULL,
user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
-- Typ konwersji
event_type VARCHAR(50) NOT NULL, -- register, login, contact_click, rsvp, message, classified
event_category VARCHAR(50), -- engagement, acquisition, activation
-- Kontekst
company_id INTEGER REFERENCES companies(id) ON DELETE SET NULL, -- Dla contact_click
target_type VARCHAR(50), -- email, phone, website, rsvp_event, etc.
target_value VARCHAR(500), -- np. adres email, id eventu
-- Źródło konwersji
source_page VARCHAR(500), -- URL strony na której nastąpiła konwersja
referrer VARCHAR(500),
-- Dodatkowe dane
event_metadata JSONB, -- Elastyczne dane kontekstowe
-- Timing
converted_at TIMESTAMP NOT NULL DEFAULT NOW(),
created_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_conversion_events_type ON conversion_events(event_type);
CREATE INDEX IF NOT EXISTS idx_conversion_events_converted_at ON conversion_events(converted_at);
CREATE INDEX IF NOT EXISTS idx_conversion_events_session ON conversion_events(session_id);
CREATE INDEX IF NOT EXISTS idx_conversion_events_user ON conversion_events(user_id);
CREATE INDEX IF NOT EXISTS idx_conversion_events_company ON conversion_events(company_id) WHERE company_id IS NOT NULL;
COMMENT ON TABLE conversion_events IS 'Kluczowe konwersje: rejestracje, kontakty, RSVP';
-- ============================================================
-- 5. JS ERRORS - Nowa tabela
-- ============================================================
CREATE TABLE IF NOT EXISTS js_errors (
id SERIAL PRIMARY KEY,
session_id INTEGER REFERENCES user_sessions(id) ON DELETE SET NULL,
-- Błąd
message TEXT NOT NULL,
source VARCHAR(500), -- URL pliku JS
lineno INTEGER,
colno INTEGER,
stack TEXT, -- Stack trace
-- Kontekst
url VARCHAR(2000), -- URL strony gdzie wystąpił błąd
user_agent VARCHAR(500),
-- Agregacja
error_hash VARCHAR(64), -- SHA256 z message+source+lineno dla grupowania
occurred_at TIMESTAMP NOT NULL DEFAULT NOW(),
created_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_js_errors_hash ON js_errors(error_hash);
CREATE INDEX IF NOT EXISTS idx_js_errors_occurred_at ON js_errors(occurred_at);
CREATE INDEX IF NOT EXISTS idx_js_errors_source ON js_errors(source);
COMMENT ON TABLE js_errors IS 'Błędy JavaScript zgłaszane z przeglądarek użytkowników';
-- ============================================================
-- 6. PERFORMANCE METRICS - Dodanie do page_views
-- ============================================================
ALTER TABLE page_views ADD COLUMN IF NOT EXISTS dom_content_loaded_ms INTEGER;
ALTER TABLE page_views ADD COLUMN IF NOT EXISTS load_time_ms INTEGER;
ALTER TABLE page_views ADD COLUMN IF NOT EXISTS first_paint_ms INTEGER;
ALTER TABLE page_views ADD COLUMN IF NOT EXISTS first_contentful_paint_ms INTEGER;
COMMENT ON COLUMN page_views.dom_content_loaded_ms IS 'Czas do DOMContentLoaded (ms)';
COMMENT ON COLUMN page_views.load_time_ms IS 'Pełny czas ładowania strony (ms)';
COMMENT ON COLUMN page_views.first_paint_ms IS 'Czas do First Paint (ms)';
COMMENT ON COLUMN page_views.first_contentful_paint_ms IS 'Czas do First Contentful Paint (ms)';
-- ============================================================
-- 7. AGREGATY DZIENNE - Rozszerzenie analytics_daily
-- ============================================================
-- Bounce rate (już istnieje, ale upewniamy się)
ALTER TABLE analytics_daily ADD COLUMN IF NOT EXISTS bounce_rate NUMERIC(5,2);
-- Nowe metryki
ALTER TABLE analytics_daily ADD COLUMN IF NOT EXISTS conversions_count INTEGER DEFAULT 0;
ALTER TABLE analytics_daily ADD COLUMN IF NOT EXISTS searches_count INTEGER DEFAULT 0;
ALTER TABLE analytics_daily ADD COLUMN IF NOT EXISTS searches_no_results INTEGER DEFAULT 0;
ALTER TABLE analytics_daily ADD COLUMN IF NOT EXISTS avg_scroll_depth NUMERIC(5,2);
ALTER TABLE analytics_daily ADD COLUMN IF NOT EXISTS js_errors_count INTEGER DEFAULT 0;
-- UTM breakdown (JSONB dla elastyczności)
ALTER TABLE analytics_daily ADD COLUMN IF NOT EXISTS utm_breakdown JSONB;
-- Konwersje wg typu
ALTER TABLE analytics_daily ADD COLUMN IF NOT EXISTS conversions_breakdown JSONB;
COMMENT ON COLUMN analytics_daily.bounce_rate IS 'Procent sesji z 1 pageview lub <10s';
COMMENT ON COLUMN analytics_daily.conversions_count IS 'Łączna liczba konwersji';
COMMENT ON COLUMN analytics_daily.searches_count IS 'Liczba wyszukiwań';
COMMENT ON COLUMN analytics_daily.searches_no_results IS 'Wyszukiwania bez wyników';
COMMENT ON COLUMN analytics_daily.avg_scroll_depth IS 'Średnia głębokość scrollowania (%)';
COMMENT ON COLUMN analytics_daily.utm_breakdown IS 'Rozkład sesji wg UTM source';
COMMENT ON COLUMN analytics_daily.conversions_breakdown IS 'Rozkład konwersji wg typu';
-- ============================================================
-- 8. POPULAR SEARCHES - Agregat dzienny
-- ============================================================
CREATE TABLE IF NOT EXISTS popular_searches_daily (
id SERIAL PRIMARY KEY,
date DATE NOT NULL,
query_normalized VARCHAR(500) NOT NULL,
search_count INTEGER DEFAULT 0,
unique_users INTEGER DEFAULT 0,
click_count INTEGER DEFAULT 0, -- Ile razy kliknięto wynik
avg_results_count NUMERIC(10,2),
UNIQUE(date, query_normalized)
);
CREATE INDEX IF NOT EXISTS idx_popular_searches_date ON popular_searches_daily(date);
COMMENT ON TABLE popular_searches_daily IS 'Popularne wyszukiwania - dzienne agregaty';
-- ============================================================
-- 9. HOURLY ACTIVITY - Dla wzorców czasowych
-- ============================================================
CREATE TABLE IF NOT EXISTS hourly_activity (
id SERIAL PRIMARY KEY,
date DATE NOT NULL,
hour INTEGER NOT NULL CHECK (hour >= 0 AND hour <= 23),
sessions_count INTEGER DEFAULT 0,
page_views_count INTEGER DEFAULT 0,
unique_users INTEGER DEFAULT 0,
UNIQUE(date, hour)
);
CREATE INDEX IF NOT EXISTS idx_hourly_activity_date ON hourly_activity(date);
COMMENT ON TABLE hourly_activity IS 'Aktywność wg godziny - dla analizy wzorców czasowych';
-- ============================================================
-- 10. UPRAWNIENIA
-- ============================================================
-- Nadanie uprawnień dla roli nordabiz_app
GRANT ALL ON TABLE search_queries TO nordabiz_app;
GRANT ALL ON TABLE conversion_events TO nordabiz_app;
GRANT ALL ON TABLE js_errors TO nordabiz_app;
GRANT ALL ON TABLE popular_searches_daily TO nordabiz_app;
GRANT ALL ON TABLE hourly_activity TO nordabiz_app;
GRANT USAGE, SELECT ON SEQUENCE search_queries_id_seq TO nordabiz_app;
GRANT USAGE, SELECT ON SEQUENCE conversion_events_id_seq TO nordabiz_app;
GRANT USAGE, SELECT ON SEQUENCE js_errors_id_seq TO nordabiz_app;
GRANT USAGE, SELECT ON SEQUENCE popular_searches_daily_id_seq TO nordabiz_app;
GRANT USAGE, SELECT ON SEQUENCE hourly_activity_id_seq TO nordabiz_app;
COMMIT;
-- ============================================================
-- WERYFIKACJA
-- ============================================================
-- Sprawdź czy wszystkie tabele istnieją
DO $$
BEGIN
RAISE NOTICE 'Weryfikacja migracji 033_analytics_expansion:';
RAISE NOTICE '- search_queries: %', (SELECT COUNT(*) FROM information_schema.tables WHERE table_name = 'search_queries');
RAISE NOTICE '- conversion_events: %', (SELECT COUNT(*) FROM information_schema.tables WHERE table_name = 'conversion_events');
RAISE NOTICE '- js_errors: %', (SELECT COUNT(*) FROM information_schema.tables WHERE table_name = 'js_errors');
RAISE NOTICE '- popular_searches_daily: %', (SELECT COUNT(*) FROM information_schema.tables WHERE table_name = 'popular_searches_daily');
RAISE NOTICE '- hourly_activity: %', (SELECT COUNT(*) FROM information_schema.tables WHERE table_name = 'hourly_activity');
RAISE NOTICE 'Migracja 033 zakończona pomyślnie!';
END $$;

View File

@ -239,6 +239,34 @@ def get_country_code(ip_address: str) -> str:
return None
def get_geoip_info(ip_address: str) -> dict:
"""
Get full GeoIP information for an IP address.
Returns:
dict with keys: country, country_name, city, region
(city and region require GeoLite2-City database)
"""
reader = get_geoip_reader()
if not reader:
return None
# Skip local/private IPs
if ip_address and ip_address.startswith(('10.', '192.168.', '172.', '127.', '::1')):
return {'country': 'LOCAL', 'country_name': 'Local Network', 'city': None, 'region': None}
try:
response = reader.country(ip_address)
return {
'country': response.country.iso_code,
'country_name': response.country.name,
'city': None, # Wymaga bazy GeoLite2-City
'region': None # Wymaga bazy GeoLite2-City
}
except Exception:
return None
def is_ip_allowed(ip_address: str = None) -> bool:
"""
Check if an IP address is allowed (not from blocked high-risk countries).

View File

@ -1,17 +1,24 @@
/**
* NordaBiz Analytics Tracker
* Tracks user clicks, time on page, and session heartbeats
* Tracks user clicks, scroll depth, time on page, performance, and JS errors
* Created: 2026-01-13
* Updated: 2026-01-30 (Analytics Expansion)
*/
(function() {
'use strict';
const HEARTBEAT_INTERVAL = 30000; // 30 seconds
const SCROLL_DEBOUNCE_MS = 500;
const TRACK_ENDPOINT = '/api/analytics/track';
const HEARTBEAT_ENDPOINT = '/api/analytics/heartbeat';
const SCROLL_ENDPOINT = '/api/analytics/scroll';
const ERROR_ENDPOINT = '/api/analytics/error';
const PERFORMANCE_ENDPOINT = '/api/analytics/performance';
let pageStartTime = Date.now();
let currentPageViewId = null;
let maxScrollDepth = 0;
let scrollTimeout = null;
// Get page view ID from meta tag (set by Flask)
function init() {
@ -29,8 +36,28 @@
// Track time on page before leaving
window.addEventListener('beforeunload', handleUnload);
// Also track visibility change (tab switch)
// Track visibility change (tab switch)
document.addEventListener('visibilitychange', handleVisibilityChange);
// Track scroll depth
window.addEventListener('scroll', handleScroll, { passive: true });
// Track JS errors
window.onerror = handleError;
window.addEventListener('unhandledrejection', handlePromiseRejection);
// Track performance metrics (after page load)
if (document.readyState === 'complete') {
trackPerformance();
} else {
window.addEventListener('load', function() {
// Wait a bit for all metrics to be available
setTimeout(trackPerformance, 100);
});
}
// Track contact clicks (conversion tracking)
trackContactClicks();
}
function handleClick(e) {
@ -81,6 +108,14 @@
});
navigator.sendBeacon(TRACK_ENDPOINT, data);
// Also send final scroll depth
if (maxScrollDepth > 0) {
navigator.sendBeacon(SCROLL_ENDPOINT, JSON.stringify({
page_view_id: currentPageViewId,
scroll_depth: maxScrollDepth
}));
}
}
function handleVisibilityChange() {
@ -95,6 +130,193 @@
}
}
// ============================================================
// SCROLL DEPTH TRACKING
// ============================================================
function handleScroll() {
// Debounce scroll events
if (scrollTimeout) {
clearTimeout(scrollTimeout);
}
scrollTimeout = setTimeout(function() {
calculateScrollDepth();
}, SCROLL_DEBOUNCE_MS);
}
function calculateScrollDepth() {
const scrollTop = window.pageYOffset || document.documentElement.scrollTop;
const scrollHeight = document.documentElement.scrollHeight;
const clientHeight = document.documentElement.clientHeight;
// Calculate percentage (0-100)
const scrollPercent = Math.round((scrollTop + clientHeight) / scrollHeight * 100);
// Only track if increased (max depth)
if (scrollPercent > maxScrollDepth) {
maxScrollDepth = Math.min(scrollPercent, 100);
// Send to server at milestones: 25%, 50%, 75%, 100%
if (currentPageViewId && (maxScrollDepth === 25 || maxScrollDepth === 50 ||
maxScrollDepth === 75 || maxScrollDepth >= 95)) {
fetch(SCROLL_ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
page_view_id: currentPageViewId,
scroll_depth: maxScrollDepth
}),
credentials: 'same-origin'
}).catch(function() {
// Silently fail
});
}
}
}
// ============================================================
// ERROR TRACKING
// ============================================================
function handleError(message, source, lineno, colno, error) {
const errorData = {
message: message ? message.toString() : 'Unknown error',
source: source,
lineno: lineno,
colno: colno,
stack: error && error.stack ? error.stack : null,
url: window.location.href
};
fetch(ERROR_ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(errorData),
credentials: 'same-origin'
}).catch(function() {
// Silently fail - don't cause more errors
});
// Don't prevent default error handling
return false;
}
function handlePromiseRejection(event) {
const reason = event.reason;
handleError(
'Unhandled Promise Rejection: ' + (reason && reason.message ? reason.message : String(reason)),
null,
null,
null,
reason instanceof Error ? reason : null
);
}
// ============================================================
// PERFORMANCE TRACKING
// ============================================================
function trackPerformance() {
if (!currentPageViewId) return;
if (!window.performance || !performance.timing) return;
const timing = performance.timing;
const navStart = timing.navigationStart;
// Calculate metrics
const metrics = {
page_view_id: currentPageViewId,
dom_content_loaded_ms: timing.domContentLoadedEventEnd - navStart,
load_time_ms: timing.loadEventEnd - navStart
};
// Get paint metrics if available
if (performance.getEntriesByType) {
const paintEntries = performance.getEntriesByType('paint');
paintEntries.forEach(function(entry) {
if (entry.name === 'first-paint') {
metrics.first_paint_ms = Math.round(entry.startTime);
}
if (entry.name === 'first-contentful-paint') {
metrics.first_contentful_paint_ms = Math.round(entry.startTime);
}
});
}
// Only send if we have valid data
if (metrics.load_time_ms > 0 && metrics.load_time_ms < 300000) {
fetch(PERFORMANCE_ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(metrics),
credentials: 'same-origin'
}).catch(function() {
// Silently fail
});
}
}
// ============================================================
// CONVERSION TRACKING (Contact Clicks)
// ============================================================
function trackContactClicks() {
// Track email clicks
document.querySelectorAll('a[href^="mailto:"]').forEach(function(link) {
link.addEventListener('click', function(e) {
trackConversion('contact_click', 'email', link.href.replace('mailto:', ''));
});
});
// Track phone clicks
document.querySelectorAll('a[href^="tel:"]').forEach(function(link) {
link.addEventListener('click', function(e) {
trackConversion('contact_click', 'phone', link.href.replace('tel:', ''));
});
});
// Track website clicks (external links from company pages)
if (window.location.pathname.startsWith('/company/')) {
document.querySelectorAll('a[target="_blank"][href^="http"]').forEach(function(link) {
// Only track links that look like company websites (not social media)
const href = link.href.toLowerCase();
if (!href.includes('facebook.com') && !href.includes('linkedin.com') &&
!href.includes('instagram.com') && !href.includes('twitter.com')) {
link.addEventListener('click', function(e) {
trackConversion('contact_click', 'website', link.href);
});
}
});
}
}
function trackConversion(eventType, targetType, targetValue) {
// Get company_id from page if available
let companyId = null;
const companyMeta = document.querySelector('meta[name="company-id"]');
if (companyMeta && companyMeta.content) {
companyId = parseInt(companyMeta.content, 10);
}
fetch('/api/analytics/conversion', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
event_type: eventType,
target_type: targetType,
target_value: targetValue,
company_id: companyId
}),
credentials: 'same-origin'
}).catch(function() {
// Silently fail
});
}
// ============================================================
// HELPERS
// ============================================================
function sendHeartbeat() {
fetch(HEARTBEAT_ENDPOINT, {
method: 'POST',

View File

@ -556,6 +556,27 @@
<span class="stat-number">{{ (stats.avg_duration / 60)|round(1) if stats.avg_duration else 0 }} min</span>
<span class="stat-label">Śr. czas sesji</span>
</div>
<div class="stat-card">
<div class="stat-icon" style="background: #fee2e2; color: #dc2626;">
<svg width="24" height="24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/>
<polyline points="16 17 21 12 16 7"/>
<line x1="21" y1="12" x2="9" y2="12"/>
</svg>
</div>
<span class="stat-number">{{ stats.bounce_rate|default(0) }}%</span>
<span class="stat-label">Bounce rate</span>
</div>
<div class="stat-card">
<div class="stat-icon" style="background: #dbeafe; color: #2563eb;">
<svg width="24" height="24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="12" y1="5" x2="12" y2="19"/>
<polyline points="19 12 12 19 5 12"/>
</svg>
</div>
<span class="stat-number">{{ stats.avg_scroll_depth|default(0) }}%</span>
<span class="stat-label">Śr. scroll</span>
</div>
</div>
<div class="two-columns">
@ -764,6 +785,203 @@
{% endfor %}
</div>
</div>
<!-- ============================================================ -->
<!-- NOWE SEKCJE (Analytics Expansion 2026-01-30) -->
<!-- ============================================================ -->
<div class="two-columns" style="margin-top: var(--spacing-xl);">
<!-- Geolokalizacja -->
<div class="section-card">
<div class="section-header">
<h3>Kraje użytkowników</h3>
</div>
<div class="section-body">
{% if country_stats %}
<div style="display: flex; flex-direction: column; gap: var(--spacing-sm);">
{% for country, count in country_stats.items() %}
<div style="display: flex; justify-content: space-between; align-items: center; padding: var(--spacing-xs) 0; border-bottom: 1px solid var(--border);">
<span style="font-weight: 500;">{{ country or 'Nieznany' }}</span>
<span style="background: var(--primary); color: white; padding: 2px 8px; border-radius: var(--radius); font-size: var(--font-size-sm);">{{ count }}</span>
</div>
{% endfor %}
</div>
{% else %}
<p class="empty-state">Brak danych geolokalizacji</p>
{% endif %}
</div>
</div>
<!-- UTM Sources -->
<div class="section-card">
<div class="section-header">
<h3>Źródła ruchu (UTM)</h3>
</div>
<div class="section-body">
{% if utm_stats %}
<div style="display: flex; flex-direction: column; gap: var(--spacing-sm);">
{% for source, count in utm_stats.items() %}
<div style="display: flex; justify-content: space-between; align-items: center; padding: var(--spacing-xs) 0; border-bottom: 1px solid var(--border);">
<span style="font-weight: 500;">{{ source }}</span>
<span style="background: #22c55e; color: white; padding: 2px 8px; border-radius: var(--radius); font-size: var(--font-size-sm);">{{ count }}</span>
</div>
{% endfor %}
</div>
{% else %}
<p style="color: var(--text-secondary); text-align: center; padding: var(--spacing-lg);">Brak danych UTM. Użyj linków z parametrami ?utm_source=...</p>
{% endif %}
</div>
</div>
</div>
<div class="two-columns" style="margin-top: var(--spacing-xl);">
<!-- Top wyszukiwania -->
<div class="section-card">
<div class="section-header">
<h3>Top wyszukiwania</h3>
</div>
<div class="table-scroll">
<table class="analytics-table">
<thead>
<tr>
<th>Fraza</th>
<th>Liczba</th>
<th>Śr. wyników</th>
</tr>
</thead>
<tbody>
{% for search in top_searches %}
<tr>
<td style="font-weight: 500;">{{ search.query_normalized }}</td>
<td>{{ search.count }}</td>
<td>{{ search.avg_results|round(1) if search.avg_results else 0 }}</td>
</tr>
{% else %}
<tr>
<td colspan="3" class="empty-state">Brak danych wyszukiwań</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<!-- Wyszukiwania bez wyników -->
<div class="section-card">
<div class="section-header">
<h3>Wyszukiwania bez wyników</h3>
</div>
<div class="table-scroll">
<table class="analytics-table">
<thead>
<tr>
<th>Fraza</th>
<th>Liczba</th>
</tr>
</thead>
<tbody>
{% for search in searches_no_results %}
<tr>
<td style="color: #dc2626; font-weight: 500;">{{ search.query_normalized }}</td>
<td>{{ search.count }}</td>
</tr>
{% else %}
<tr>
<td colspan="2" class="empty-state">Wszystkie wyszukiwania miały wyniki</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
<div class="two-columns" style="margin-top: var(--spacing-xl);">
<!-- Konwersje -->
<div class="section-card">
<div class="section-header">
<h3>Konwersje</h3>
</div>
<div class="section-body">
{% if conversion_stats %}
<div style="display: grid; grid-template-columns: repeat(2, 1fr); gap: var(--spacing-md);">
{% for event_type, count in conversion_stats.items() %}
<div style="background: var(--background); padding: var(--spacing-md); border-radius: var(--radius); text-align: center;">
<div style="font-size: var(--font-size-2xl); font-weight: 700; color: var(--primary);">{{ count }}</div>
<div style="font-size: var(--font-size-sm); color: var(--text-secondary);">
{% if event_type == 'register' %}Rejestracje
{% elif event_type == 'login' %}Logowania
{% elif event_type == 'contact_click' %}Kliknięcia kontakt
{% elif event_type == 'rsvp' %}RSVP wydarzenia
{% elif event_type == 'message' %}Wiadomości
{% elif event_type == 'classified' %}Ogłoszenia B2B
{% else %}{{ event_type }}
{% endif %}
</div>
</div>
{% endfor %}
</div>
{% else %}
<p class="empty-state">Brak konwersji w wybranym okresie</p>
{% endif %}
</div>
</div>
<!-- Błędy JS -->
<div class="section-card">
<div class="section-header">
<h3>Błędy JavaScript</h3>
</div>
<div class="table-scroll">
<table class="analytics-table">
<thead>
<tr>
<th>Błąd</th>
<th>Źródło</th>
<th>Liczba</th>
</tr>
</thead>
<tbody>
{% for error in js_errors %}
<tr>
<td style="font-family: monospace; font-size: var(--font-size-sm); color: #dc2626; max-width: 200px; overflow: hidden; text-overflow: ellipsis;">{{ error.message[:80] }}...</td>
<td style="font-family: monospace; font-size: var(--font-size-sm); max-width: 150px; overflow: hidden; text-overflow: ellipsis;">{{ error.source|default('?') }}</td>
<td>{{ error.count }}</td>
</tr>
{% else %}
<tr>
<td colspan="3" class="empty-state" style="color: #22c55e;">Brak błędów JS</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
<!-- Eksport danych -->
<div class="section-card" style="margin-top: var(--spacing-xl);">
<div class="section-header">
<h3>Eksport danych</h3>
</div>
<div class="section-body">
<p style="margin-bottom: var(--spacing-md); color: var(--text-secondary);">Pobierz dane analityczne w formacie CSV</p>
<div style="display: flex; flex-wrap: wrap; gap: var(--spacing-sm);">
<a href="{{ url_for('admin_analytics_export', type='sessions', period=current_period) }}" class="btn btn-secondary" style="text-decoration: none;">
Sesje (CSV)
</a>
<a href="{{ url_for('admin_analytics_export', type='pageviews', period=current_period) }}" class="btn btn-secondary" style="text-decoration: none;">
Page views (CSV)
</a>
<a href="{{ url_for('admin_analytics_export', type='searches', period=current_period) }}" class="btn btn-secondary" style="text-decoration: none;">
Wyszukiwania (CSV)
</a>
<a href="{{ url_for('admin_analytics_export', type='conversions', period=current_period) }}" class="btn btn-secondary" style="text-decoration: none;">
Konwersje (CSV)
</a>
</div>
</div>
</div>
</div>
</main>
{% endblock %}

View File

@ -7,6 +7,7 @@
<meta name="author" content="Norda Biznes">
<meta name="robots" content="index, follow">
<meta name="page-view-id" content="{{ page_view_id|default('') }}">
{% if company_id %}<meta name="company-id" content="{{ company_id }}">{% endif %}
<meta name="csrf-token" content="{{ csrf_token() }}">
<title>{% block title %}Norda Biznes Partner{% endblock %}</title>