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:
parent
64583b6ec4
commit
0055857df4
475
app.py
475
app.py
@ -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}")
|
||||||
|
|||||||
181
database.py
181
database.py
@ -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
|
||||||
# ============================================================
|
# ============================================================
|
||||||
|
|||||||
264
database/migrations/033_analytics_expansion.sql
Normal file
264
database/migrations/033_analytics_expansion.sql
Normal 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 $$;
|
||||||
@ -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).
|
||||||
|
|||||||
@ -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',
|
||||||
|
|||||||
@ -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 %}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user