From 0055857df4c371a8ebfc40cb85becb551acd081b Mon Sep 17 00:00:00 2001 From: Maciej Pienczyn Date: Fri, 30 Jan 2026 15:52:18 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20Rozbudowa=20systemu=20analityki=20u?= =?UTF-8?q?=C5=BCytkownik=C3=B3w?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- app.py | 475 +++++++++++++++++- database.py | 181 +++++++ .../migrations/033_analytics_expansion.sql | 264 ++++++++++ security_service.py | 28 ++ static/js/analytics-tracker.js | 226 ++++++++- templates/admin/analytics_dashboard.html | 218 ++++++++ templates/base.html | 1 + 7 files changed, 1387 insertions(+), 6 deletions(-) create mode 100644 database/migrations/033_analytics_expansion.sql diff --git a/app.py b/app.py index a54549a..382ac47 100644 --- a/app.py +++ b/app.py @@ -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}") diff --git a/database.py b/database.py index 20381d4..7de34b5 100644 --- a/database.py +++ b/database.py @@ -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"" +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"" + + +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"" + + +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"" + + +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"" + + +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"" + + # ============================================================ # EMAIL LOGGING # ============================================================ diff --git a/database/migrations/033_analytics_expansion.sql b/database/migrations/033_analytics_expansion.sql new file mode 100644 index 0000000..4bb15ac --- /dev/null +++ b/database/migrations/033_analytics_expansion.sql @@ -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 $$; diff --git a/security_service.py b/security_service.py index 991806c..8cbe61a 100644 --- a/security_service.py +++ b/security_service.py @@ -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). diff --git a/static/js/analytics-tracker.js b/static/js/analytics-tracker.js index 720b061..215a55c 100644 --- a/static/js/analytics-tracker.js +++ b/static/js/analytics-tracker.js @@ -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', diff --git a/templates/admin/analytics_dashboard.html b/templates/admin/analytics_dashboard.html index 14f0b91..d425692 100644 --- a/templates/admin/analytics_dashboard.html +++ b/templates/admin/analytics_dashboard.html @@ -556,6 +556,27 @@ {{ (stats.avg_duration / 60)|round(1) if stats.avg_duration else 0 }} min Śr. czas sesji +
+
+ + + + + +
+ {{ stats.bounce_rate|default(0) }}% + Bounce rate +
+
+
+ + + + +
+ {{ stats.avg_scroll_depth|default(0) }}% + Śr. scroll +
@@ -764,6 +785,203 @@ {% endfor %}
+ + + + + +
+ +
+
+

Kraje użytkowników

+
+
+ {% if country_stats %} +
+ {% for country, count in country_stats.items() %} +
+ {{ country or 'Nieznany' }} + {{ count }} +
+ {% endfor %} +
+ {% else %} +

Brak danych geolokalizacji

+ {% endif %} +
+
+ + +
+
+

Źródła ruchu (UTM)

+
+
+ {% if utm_stats %} +
+ {% for source, count in utm_stats.items() %} +
+ {{ source }} + {{ count }} +
+ {% endfor %} +
+ {% else %} +

Brak danych UTM. Użyj linków z parametrami ?utm_source=...

+ {% endif %} +
+
+
+ +
+ +
+
+

Top wyszukiwania

+
+
+ + + + + + + + + + {% for search in top_searches %} + + + + + + {% else %} + + + + {% endfor %} + +
FrazaLiczbaŚr. wyników
{{ search.query_normalized }}{{ search.count }}{{ search.avg_results|round(1) if search.avg_results else 0 }}
Brak danych wyszukiwań
+
+
+ + +
+
+

Wyszukiwania bez wyników

+
+
+ + + + + + + + + {% for search in searches_no_results %} + + + + + {% else %} + + + + {% endfor %} + +
FrazaLiczba
{{ search.query_normalized }}{{ search.count }}
Wszystkie wyszukiwania miały wyniki
+
+
+
+ +
+ +
+
+

Konwersje

+
+
+ {% if conversion_stats %} +
+ {% for event_type, count in conversion_stats.items() %} +
+
{{ count }}
+
+ {% 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 %} +
+
+ {% endfor %} +
+ {% else %} +

Brak konwersji w wybranym okresie

+ {% endif %} +
+
+ + +
+
+

Błędy JavaScript

+
+
+ + + + + + + + + + {% for error in js_errors %} + + + + + + {% else %} + + + + {% endfor %} + +
BłądŹródłoLiczba
{{ error.message[:80] }}...{{ error.source|default('?') }}{{ error.count }}
Brak błędów JS
+
+
+
+ + +
+
+

Eksport danych

+
+ +
{% endblock %} diff --git a/templates/base.html b/templates/base.html index 3d603c6..70407dc 100755 --- a/templates/base.html +++ b/templates/base.html @@ -7,6 +7,7 @@ + {% if company_id %}{% endif %} {% block title %}Norda Biznes Partner{% endblock %}