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, UserClick,
AnalyticsDaily, AnalyticsDaily,
PopularPagesDaily, PopularPagesDaily,
SearchQuery,
ConversionEvent,
JSError,
PopularSearchesDaily,
HourlyActivity,
AuditLog, AuditLog,
SecurityAlert, SecurityAlert,
ZOPKNews ZOPKNews
@ -420,6 +425,7 @@ def get_or_create_analytics_session():
""" """
Get existing analytics session or create new one. Get existing analytics session or create new one.
Returns the database session ID (integer). Returns the database session ID (integer).
Includes GeoIP lookup and UTM parameter parsing.
""" """
analytics_session_id = session.get('analytics_session_id') analytics_session_id = session.get('analytics_session_id')
@ -448,16 +454,48 @@ def get_or_create_analytics_session():
os_name = 'Unknown' os_name = 'Unknown'
os_version = '' 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( user_session = UserSession(
session_id=analytics_session_id, session_id=analytics_session_id,
user_id=current_user.id if current_user.is_authenticated else None, 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, user_agent=ua_string[:2000] if ua_string else None,
device_type=device_type, device_type=device_type,
browser=browser[:50] if browser else None, browser=browser[:50] if browser else None,
browser_version=browser_version[:20] if browser_version else None, browser_version=browser_version[:20] if browser_version else None,
os=os_name[:50] if os_name 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.add(user_session)
db.commit() db.commit()
@ -478,6 +516,63 @@ def get_or_create_analytics_session():
db.close() 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 @app.before_request
def check_geoip(): def check_geoip():
"""Block requests from high-risk countries (RU, CN, KP, IR, BY, SY, VE, CU).""" """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', return render_template('company_detail.html',
company=company, company=company,
company_id=company.id, # For analytics conversion tracking
maturity_data=maturity_data, maturity_data=maturity_data,
website_analysis=website_analysis, website_analysis=website_analysis,
quality_data=quality_data, quality_data=quality_data,
@ -1293,6 +1389,32 @@ def search():
# Extract companies from SearchResult objects # Extract companies from SearchResult objects
companies = [r.company for r in results] 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 # For debugging/analytics - log search stats
if query: if query:
match_types = {} match_types = {}
@ -3372,6 +3494,147 @@ def api_analytics_heartbeat():
db.close() 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 # RECOMMENDATIONS API ROUTES
# ============================================================ # ============================================================
@ -7555,6 +7818,94 @@ def admin_analytics():
'pages': user_pages '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( return render_template(
'admin/analytics_dashboard.html', 'admin/analytics_dashboard.html',
stats=stats, stats=stats,
@ -7563,7 +7914,15 @@ def admin_analytics():
popular_pages=popular_pages, popular_pages=popular_pages,
recent_sessions=recent_sessions, recent_sessions=recent_sessions,
user_detail=user_detail, 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: except Exception as e:
logger.error(f"Admin analytics error: {e}") logger.error(f"Admin analytics error: {e}")
@ -7573,6 +7932,113 @@ def admin_analytics():
db.close() 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') @app.route('/api/admin/ai-learning-status')
@login_required @login_required
def api_ai_learning_status(): def api_ai_learning_status():
@ -14888,7 +15354,8 @@ def honeypot_trap(path=None):
# ============================================================ # ============================================================
if __name__ == '__main__': 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' debug = os.getenv('FLASK_ENV') == 'development'
logger.info(f"Starting Norda Biznes Partner on port {port}") 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) page_views_count = Column(Integer, default=0)
clicks_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) created_at = Column(DateTime, default=datetime.now)
# Relationships # Relationships
@ -2796,6 +2803,15 @@ class PageView(Base):
viewed_at = Column(DateTime, nullable=False, default=datetime.now, index=True) viewed_at = Column(DateTime, nullable=False, default=datetime.now, index=True)
time_on_page_seconds = Column(Integer, nullable=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 # Kontekst
company_id = Column(Integer, ForeignKey('companies.id', ondelete='SET NULL'), nullable=True) company_id = Column(Integer, ForeignKey('companies.id', ondelete='SET NULL'), nullable=True)
@ -2872,6 +2888,17 @@ class AnalyticsDaily(Base):
# Engagement # Engagement
bounce_rate = Column(Numeric(5, 2), nullable=True) 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) updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
def __repr__(self): def __repr__(self):
@ -2901,6 +2928,160 @@ class PopularPagesDaily(Base):
return f"<PopularPagesDaily {self.date} {self.path}>" 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 # 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 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: def is_ip_allowed(ip_address: str = None) -> bool:
""" """
Check if an IP address is allowed (not from blocked high-risk countries). Check if an IP address is allowed (not from blocked high-risk countries).

View File

@ -1,17 +1,24 @@
/** /**
* NordaBiz Analytics Tracker * 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 * Created: 2026-01-13
* Updated: 2026-01-30 (Analytics Expansion)
*/ */
(function() { (function() {
'use strict'; 'use strict';
const HEARTBEAT_INTERVAL = 30000; // 30 seconds const HEARTBEAT_INTERVAL = 30000; // 30 seconds
const SCROLL_DEBOUNCE_MS = 500;
const TRACK_ENDPOINT = '/api/analytics/track'; const TRACK_ENDPOINT = '/api/analytics/track';
const HEARTBEAT_ENDPOINT = '/api/analytics/heartbeat'; 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 pageStartTime = Date.now();
let currentPageViewId = null; let currentPageViewId = null;
let maxScrollDepth = 0;
let scrollTimeout = null;
// Get page view ID from meta tag (set by Flask) // Get page view ID from meta tag (set by Flask)
function init() { function init() {
@ -29,8 +36,28 @@
// Track time on page before leaving // Track time on page before leaving
window.addEventListener('beforeunload', handleUnload); window.addEventListener('beforeunload', handleUnload);
// Also track visibility change (tab switch) // Track visibility change (tab switch)
document.addEventListener('visibilitychange', handleVisibilityChange); 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) { function handleClick(e) {
@ -81,6 +108,14 @@
}); });
navigator.sendBeacon(TRACK_ENDPOINT, data); 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() { 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() { function sendHeartbeat() {
fetch(HEARTBEAT_ENDPOINT, { fetch(HEARTBEAT_ENDPOINT, {
method: 'POST', 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-number">{{ (stats.avg_duration / 60)|round(1) if stats.avg_duration else 0 }} min</span>
<span class="stat-label">Śr. czas sesji</span> <span class="stat-label">Śr. czas sesji</span>
</div> </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>
<div class="two-columns"> <div class="two-columns">
@ -764,6 +785,203 @@
{% endfor %} {% endfor %}
</div> </div>
</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> </div>
</main> </main>
{% endblock %} {% endblock %}

View File

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