feat: Add user analytics panel (/admin/analytics)
- Track user sessions, page views, clicks - Measure engagement score and session duration - Device breakdown (desktop/mobile/tablet) - User rankings by activity - Popular pages statistics - Recent sessions feed - SQL migration for analytics tables - JavaScript tracker for frontend events Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
847ec1f12f
commit
a148d464af
394
app.py
394
app.py
@ -32,6 +32,8 @@ from flask_limiter import Limiter
|
||||
from flask_limiter.util import get_remote_address
|
||||
from werkzeug.security import generate_password_hash, check_password_hash
|
||||
from dotenv import load_dotenv
|
||||
from user_agents import parse as parse_user_agent
|
||||
import uuid
|
||||
|
||||
# Load environment variables (override any existing env vars)
|
||||
# Try .env first, then nordabiz_config.txt for production flexibility
|
||||
@ -114,7 +116,12 @@ from database import (
|
||||
ITAudit,
|
||||
KRSAudit,
|
||||
CompanyPKD,
|
||||
CompanyFinancialReport
|
||||
CompanyFinancialReport,
|
||||
UserSession,
|
||||
PageView,
|
||||
UserClick,
|
||||
AnalyticsDaily,
|
||||
PopularPagesDaily
|
||||
)
|
||||
|
||||
# Import services
|
||||
@ -337,6 +344,157 @@ def create_news_notification(company_id, news_id, news_title):
|
||||
db.close()
|
||||
|
||||
|
||||
# ============================================================
|
||||
# USER ANALYTICS - TRACKING HELPERS
|
||||
# ============================================================
|
||||
|
||||
# Global variable to store current page_view_id for templates
|
||||
_current_page_view_id = {}
|
||||
|
||||
def get_or_create_analytics_session():
|
||||
"""
|
||||
Get existing analytics session or create new one.
|
||||
Returns the database session ID (integer).
|
||||
"""
|
||||
analytics_session_id = session.get('analytics_session_id')
|
||||
|
||||
if not analytics_session_id:
|
||||
analytics_session_id = str(uuid.uuid4())
|
||||
session['analytics_session_id'] = analytics_session_id
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
user_session = db.query(UserSession).filter_by(session_id=analytics_session_id).first()
|
||||
|
||||
if not user_session:
|
||||
# Parse user agent
|
||||
ua_string = request.headers.get('User-Agent', '')
|
||||
try:
|
||||
ua = parse_user_agent(ua_string)
|
||||
device_type = 'mobile' if ua.is_mobile else ('tablet' if ua.is_tablet else 'desktop')
|
||||
browser = ua.browser.family
|
||||
browser_version = ua.browser.version_string
|
||||
os_name = ua.os.family
|
||||
os_version = ua.os.version_string
|
||||
except Exception:
|
||||
device_type = 'desktop'
|
||||
browser = 'Unknown'
|
||||
browser_version = ''
|
||||
os_name = 'Unknown'
|
||||
os_version = ''
|
||||
|
||||
user_session = UserSession(
|
||||
session_id=analytics_session_id,
|
||||
user_id=current_user.id if current_user.is_authenticated else None,
|
||||
ip_address=request.remote_addr,
|
||||
user_agent=ua_string[:2000] if ua_string else None,
|
||||
device_type=device_type,
|
||||
browser=browser[:50] if browser else None,
|
||||
browser_version=browser_version[:20] if browser_version else None,
|
||||
os=os_name[:50] if os_name else None,
|
||||
os_version=os_version[:20] if os_version else None
|
||||
)
|
||||
db.add(user_session)
|
||||
db.commit()
|
||||
db.refresh(user_session)
|
||||
else:
|
||||
# Update last activity
|
||||
user_session.last_activity_at = datetime.now()
|
||||
if current_user.is_authenticated and not user_session.user_id:
|
||||
user_session.user_id = current_user.id
|
||||
db.commit()
|
||||
|
||||
return user_session.id
|
||||
except Exception as e:
|
||||
logger.error(f"Analytics session error: {e}")
|
||||
db.rollback()
|
||||
return None
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@app.before_request
|
||||
def track_page_view():
|
||||
"""Track page views (excluding static files and API calls)"""
|
||||
# Skip static files
|
||||
if request.path.startswith('/static'):
|
||||
return
|
||||
|
||||
# Skip API calls except selected ones
|
||||
if request.path.startswith('/api'):
|
||||
return
|
||||
|
||||
# Skip analytics tracking endpoints
|
||||
if request.path in ['/api/analytics/track', '/api/analytics/heartbeat']:
|
||||
return
|
||||
|
||||
# Skip health checks
|
||||
if request.path == '/health':
|
||||
return
|
||||
|
||||
# Skip favicon
|
||||
if request.path == '/favicon.ico':
|
||||
return
|
||||
|
||||
try:
|
||||
session_db_id = get_or_create_analytics_session()
|
||||
if not session_db_id:
|
||||
return
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
page_view = PageView(
|
||||
session_id=session_db_id,
|
||||
user_id=current_user.id if current_user.is_authenticated else None,
|
||||
url=request.url[:2000] if request.url else '',
|
||||
path=request.path[:500] if request.path else '/',
|
||||
referrer=request.referrer[:2000] if request.referrer else None
|
||||
)
|
||||
|
||||
# Extract company_id from path if on company page
|
||||
if request.path.startswith('/company/'):
|
||||
try:
|
||||
slug = request.path.split('/')[2].split('?')[0]
|
||||
company = db.query(Company).filter_by(slug=slug).first()
|
||||
if company:
|
||||
page_view.company_id = company.id
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
db.add(page_view)
|
||||
|
||||
# Update session page count
|
||||
user_session = db.query(UserSession).filter_by(id=session_db_id).first()
|
||||
if user_session:
|
||||
user_session.page_views_count = (user_session.page_views_count or 0) + 1
|
||||
|
||||
db.commit()
|
||||
|
||||
# Store page_view_id for click tracking (in request context)
|
||||
_current_page_view_id[id(request)] = page_view.id
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Page view tracking error: {e}")
|
||||
db.rollback()
|
||||
finally:
|
||||
db.close()
|
||||
except Exception as e:
|
||||
logger.error(f"Page view tracking outer error: {e}")
|
||||
|
||||
|
||||
@app.context_processor
|
||||
def inject_page_view_id():
|
||||
"""Inject page_view_id into all templates for JS tracking"""
|
||||
page_view_id = _current_page_view_id.get(id(request), '')
|
||||
return {'page_view_id': page_view_id}
|
||||
|
||||
|
||||
@app.teardown_request
|
||||
def cleanup_page_view_id(exception=None):
|
||||
"""Clean up page_view_id from global dict after request"""
|
||||
_current_page_view_id.pop(id(request), None)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# SECURITY MIDDLEWARE & HELPERS
|
||||
# ============================================================
|
||||
@ -2937,6 +3095,92 @@ def api_notifications_unread_count():
|
||||
db.close()
|
||||
|
||||
|
||||
# ============================================================
|
||||
# USER ANALYTICS API ROUTES
|
||||
# ============================================================
|
||||
|
||||
@app.route('/api/analytics/track', methods=['POST'])
|
||||
def api_analytics_track():
|
||||
"""Track clicks and interactions from frontend"""
|
||||
data = request.get_json()
|
||||
if not data:
|
||||
return jsonify({'error': 'No data'}), 400
|
||||
|
||||
analytics_session_id = session.get('analytics_session_id')
|
||||
if not analytics_session_id:
|
||||
return jsonify({'error': 'No session'}), 400
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
user_session = db.query(UserSession).filter_by(session_id=analytics_session_id).first()
|
||||
if not user_session:
|
||||
return jsonify({'error': 'Session not found'}), 404
|
||||
|
||||
event_type = data.get('type')
|
||||
|
||||
if event_type == 'click':
|
||||
click = UserClick(
|
||||
session_id=user_session.id,
|
||||
page_view_id=data.get('page_view_id'),
|
||||
user_id=current_user.id if current_user.is_authenticated else None,
|
||||
element_type=data.get('element_type', '')[:50] if data.get('element_type') else None,
|
||||
element_id=data.get('element_id', '')[:100] if data.get('element_id') else None,
|
||||
element_text=(data.get('element_text', '') or '')[:255],
|
||||
element_class=(data.get('element_class', '') or '')[:500],
|
||||
target_url=data.get('target_url', '')[:2000] if data.get('target_url') else None,
|
||||
x_position=data.get('x'),
|
||||
y_position=data.get('y')
|
||||
)
|
||||
db.add(click)
|
||||
|
||||
user_session.clicks_count = (user_session.clicks_count or 0) + 1
|
||||
db.commit()
|
||||
|
||||
elif event_type == 'page_time':
|
||||
# Update time on page
|
||||
page_view_id = data.get('page_view_id')
|
||||
time_seconds = data.get('time_seconds')
|
||||
if page_view_id and time_seconds:
|
||||
page_view = db.query(PageView).filter_by(id=page_view_id).first()
|
||||
if page_view:
|
||||
page_view.time_on_page_seconds = min(time_seconds, 86400) # Max 24h
|
||||
db.commit()
|
||||
|
||||
return jsonify({'success': True}), 200
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Analytics track error: {e}")
|
||||
db.rollback()
|
||||
return jsonify({'error': 'Internal error'}), 500
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@app.route('/api/analytics/heartbeat', methods=['POST'])
|
||||
def api_analytics_heartbeat():
|
||||
"""Keep session alive and update duration"""
|
||||
analytics_session_id = session.get('analytics_session_id')
|
||||
if not analytics_session_id:
|
||||
return jsonify({'success': False}), 200
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
user_session = db.query(UserSession).filter_by(session_id=analytics_session_id).first()
|
||||
if user_session:
|
||||
user_session.last_activity_at = datetime.now()
|
||||
user_session.duration_seconds = int(
|
||||
(datetime.now() - user_session.started_at).total_seconds()
|
||||
)
|
||||
db.commit()
|
||||
return jsonify({'success': True}), 200
|
||||
except Exception as e:
|
||||
logger.error(f"Analytics heartbeat error: {e}")
|
||||
db.rollback()
|
||||
return jsonify({'success': False}), 200
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
# ============================================================
|
||||
# RECOMMENDATIONS API ROUTES
|
||||
# ============================================================
|
||||
@ -6390,6 +6634,154 @@ def chat_analytics():
|
||||
db.close()
|
||||
|
||||
|
||||
@app.route('/admin/analytics')
|
||||
@login_required
|
||||
def admin_analytics():
|
||||
"""Admin dashboard for user analytics - sessions, page views, clicks"""
|
||||
if not current_user.is_admin:
|
||||
flash('Brak uprawnien do tej strony.', 'error')
|
||||
return redirect(url_for('dashboard'))
|
||||
|
||||
from sqlalchemy import func, desc
|
||||
from sqlalchemy.orm import joinedload
|
||||
from datetime import date, timedelta
|
||||
|
||||
period = request.args.get('period', 'week')
|
||||
user_id = request.args.get('user_id', type=int)
|
||||
|
||||
# Period calculation
|
||||
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 = None
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
# Base query for sessions in period
|
||||
sessions_query = db.query(UserSession)
|
||||
if start_date:
|
||||
sessions_query = sessions_query.filter(
|
||||
func.date(UserSession.started_at) >= start_date
|
||||
)
|
||||
|
||||
# Overall stats
|
||||
total_sessions = sessions_query.count()
|
||||
unique_users = sessions_query.filter(
|
||||
UserSession.user_id.isnot(None)
|
||||
).distinct(UserSession.user_id).count()
|
||||
|
||||
total_page_views = db.query(func.sum(UserSession.page_views_count)).filter(
|
||||
func.date(UserSession.started_at) >= start_date if start_date else True
|
||||
).scalar() or 0
|
||||
|
||||
total_clicks = db.query(func.sum(UserSession.clicks_count)).filter(
|
||||
func.date(UserSession.started_at) >= start_date if start_date else True
|
||||
).scalar() or 0
|
||||
|
||||
avg_duration = db.query(func.avg(UserSession.duration_seconds)).filter(
|
||||
func.date(UserSession.started_at) >= start_date if start_date else True,
|
||||
UserSession.duration_seconds.isnot(None)
|
||||
).scalar() or 0
|
||||
|
||||
stats = {
|
||||
'total_sessions': total_sessions,
|
||||
'unique_users': unique_users,
|
||||
'total_page_views': int(total_page_views),
|
||||
'total_clicks': int(total_clicks),
|
||||
'avg_duration': float(avg_duration)
|
||||
}
|
||||
|
||||
# Device breakdown
|
||||
device_query = db.query(
|
||||
UserSession.device_type,
|
||||
func.count(UserSession.id)
|
||||
)
|
||||
if start_date:
|
||||
device_query = device_query.filter(
|
||||
func.date(UserSession.started_at) >= start_date
|
||||
)
|
||||
device_stats = dict(device_query.group_by(UserSession.device_type).all())
|
||||
|
||||
# Top users by engagement
|
||||
user_query = db.query(
|
||||
User.id,
|
||||
User.name,
|
||||
User.email,
|
||||
func.count(UserSession.id).label('sessions'),
|
||||
func.sum(UserSession.page_views_count).label('page_views'),
|
||||
func.sum(UserSession.clicks_count).label('clicks'),
|
||||
func.sum(UserSession.duration_seconds).label('total_time')
|
||||
).join(UserSession, User.id == UserSession.user_id)
|
||||
|
||||
if start_date:
|
||||
user_query = user_query.filter(
|
||||
func.date(UserSession.started_at) >= start_date
|
||||
)
|
||||
|
||||
user_rankings = user_query.group_by(User.id).order_by(
|
||||
desc('page_views')
|
||||
).limit(20).all()
|
||||
|
||||
# Popular pages
|
||||
page_query = db.query(
|
||||
PageView.path,
|
||||
func.count(PageView.id).label('views'),
|
||||
func.count(func.distinct(PageView.user_id)).label('unique_users'),
|
||||
func.avg(PageView.time_on_page_seconds).label('avg_time')
|
||||
)
|
||||
if start_date:
|
||||
page_query = page_query.filter(
|
||||
func.date(PageView.viewed_at) >= start_date
|
||||
)
|
||||
popular_pages = page_query.group_by(PageView.path).order_by(
|
||||
desc('views')
|
||||
).limit(20).all()
|
||||
|
||||
# Recent sessions (last 50)
|
||||
recent_sessions = db.query(UserSession).options(
|
||||
joinedload(UserSession.user)
|
||||
).order_by(UserSession.started_at.desc()).limit(50).all()
|
||||
|
||||
# Single user detail (if requested)
|
||||
user_detail = None
|
||||
if user_id:
|
||||
user_obj = db.query(User).filter_by(id=user_id).first()
|
||||
user_sessions = db.query(UserSession).filter_by(user_id=user_id).order_by(
|
||||
UserSession.started_at.desc()
|
||||
).limit(20).all()
|
||||
user_pages = db.query(PageView).filter_by(user_id=user_id).order_by(
|
||||
PageView.viewed_at.desc()
|
||||
).limit(50).all()
|
||||
|
||||
user_detail = {
|
||||
'user': user_obj,
|
||||
'sessions': user_sessions,
|
||||
'pages': user_pages
|
||||
}
|
||||
|
||||
return render_template(
|
||||
'admin/analytics_dashboard.html',
|
||||
stats=stats,
|
||||
device_stats=device_stats,
|
||||
user_rankings=user_rankings,
|
||||
popular_pages=popular_pages,
|
||||
recent_sessions=recent_sessions,
|
||||
user_detail=user_detail,
|
||||
current_period=period
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Admin analytics error: {e}")
|
||||
flash('Blad podczas ladowania analityki.', 'error')
|
||||
return redirect(url_for('admin_panel'))
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@app.route('/api/admin/ai-learning-status')
|
||||
@login_required
|
||||
def api_ai_learning_status():
|
||||
|
||||
175
database.py
175
database.py
@ -2349,6 +2349,181 @@ class CompanyFinancialReport(Base):
|
||||
return f'<CompanyFinancialReport {self.period_start} - {self.period_end}>'
|
||||
|
||||
|
||||
# ============================================================
|
||||
# USER ANALYTICS - SESJE I AKTYWNOŚĆ
|
||||
# ============================================================
|
||||
|
||||
class UserSession(Base):
|
||||
"""
|
||||
Sesje użytkowników portalu.
|
||||
Śledzi czas trwania sesji, urządzenie, lokalizację i aktywność.
|
||||
"""
|
||||
__tablename__ = 'user_sessions'
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
user_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE'), nullable=True)
|
||||
session_id = Column(String(100), unique=True, nullable=False, index=True)
|
||||
|
||||
# Czas sesji
|
||||
started_at = Column(DateTime, nullable=False, default=datetime.now)
|
||||
ended_at = Column(DateTime, nullable=True)
|
||||
last_activity_at = Column(DateTime, nullable=False, default=datetime.now)
|
||||
duration_seconds = Column(Integer, nullable=True)
|
||||
|
||||
# Urządzenie
|
||||
ip_address = Column(String(45), nullable=True)
|
||||
user_agent = Column(Text, nullable=True)
|
||||
device_type = Column(String(20), nullable=True) # desktop, mobile, tablet
|
||||
browser = Column(String(50), nullable=True)
|
||||
browser_version = Column(String(20), nullable=True)
|
||||
os = Column(String(50), nullable=True)
|
||||
os_version = Column(String(20), nullable=True)
|
||||
|
||||
# Lokalizacja (z IP)
|
||||
country = Column(String(100), nullable=True)
|
||||
city = Column(String(100), nullable=True)
|
||||
region = Column(String(100), nullable=True)
|
||||
|
||||
# Metryki sesji
|
||||
page_views_count = Column(Integer, default=0)
|
||||
clicks_count = Column(Integer, default=0)
|
||||
|
||||
created_at = Column(DateTime, default=datetime.now)
|
||||
|
||||
# Relationships
|
||||
user = relationship('User', backref='analytics_sessions')
|
||||
page_views = relationship('PageView', back_populates='session', cascade='all, delete-orphan')
|
||||
|
||||
def __repr__(self):
|
||||
return f"<UserSession {self.session_id[:8]}... user={self.user_id}>"
|
||||
|
||||
|
||||
class PageView(Base):
|
||||
"""
|
||||
Wyświetlenia stron przez użytkowników.
|
||||
Śledzi odwiedzone strony i czas spędzony na każdej z nich.
|
||||
"""
|
||||
__tablename__ = 'page_views'
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
session_id = Column(Integer, ForeignKey('user_sessions.id', ondelete='CASCADE'), nullable=True)
|
||||
user_id = Column(Integer, ForeignKey('users.id', ondelete='SET NULL'), nullable=True)
|
||||
|
||||
# Strona
|
||||
url = Column(String(2000), nullable=False)
|
||||
path = Column(String(500), nullable=False, index=True)
|
||||
page_title = Column(String(500), nullable=True)
|
||||
referrer = Column(String(2000), nullable=True)
|
||||
|
||||
# Czas
|
||||
viewed_at = Column(DateTime, nullable=False, default=datetime.now, index=True)
|
||||
time_on_page_seconds = Column(Integer, nullable=True)
|
||||
|
||||
# Kontekst
|
||||
company_id = Column(Integer, ForeignKey('companies.id', ondelete='SET NULL'), nullable=True)
|
||||
|
||||
created_at = Column(DateTime, default=datetime.now)
|
||||
|
||||
# Relationships
|
||||
session = relationship('UserSession', back_populates='page_views')
|
||||
clicks = relationship('UserClick', back_populates='page_view', cascade='all, delete-orphan')
|
||||
|
||||
def __repr__(self):
|
||||
return f"<PageView {self.path}>"
|
||||
|
||||
|
||||
class UserClick(Base):
|
||||
"""
|
||||
Kliknięcia elementów na stronach.
|
||||
Śledzi interakcje użytkowników z elementami UI.
|
||||
"""
|
||||
__tablename__ = 'user_clicks'
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
session_id = Column(Integer, ForeignKey('user_sessions.id', ondelete='CASCADE'), nullable=True)
|
||||
page_view_id = Column(Integer, ForeignKey('page_views.id', ondelete='CASCADE'), nullable=True)
|
||||
user_id = Column(Integer, ForeignKey('users.id', ondelete='SET NULL'), nullable=True)
|
||||
|
||||
# Element kliknięty
|
||||
element_type = Column(String(50), nullable=True) # button, link, card, nav, form
|
||||
element_id = Column(String(100), nullable=True)
|
||||
element_text = Column(String(255), nullable=True)
|
||||
element_class = Column(String(500), nullable=True)
|
||||
target_url = Column(String(2000), nullable=True)
|
||||
|
||||
# Pozycja kliknięcia
|
||||
x_position = Column(Integer, nullable=True)
|
||||
y_position = Column(Integer, nullable=True)
|
||||
|
||||
clicked_at = Column(DateTime, nullable=False, default=datetime.now, index=True)
|
||||
|
||||
# Relationships
|
||||
page_view = relationship('PageView', back_populates='clicks')
|
||||
|
||||
def __repr__(self):
|
||||
return f"<UserClick {self.element_type} at {self.clicked_at}>"
|
||||
|
||||
|
||||
class AnalyticsDaily(Base):
|
||||
"""
|
||||
Dzienne statystyki agregowane.
|
||||
Automatycznie aktualizowane przez trigger PostgreSQL.
|
||||
"""
|
||||
__tablename__ = 'analytics_daily'
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
date = Column(Date, unique=True, nullable=False, index=True)
|
||||
|
||||
# Sesje
|
||||
total_sessions = Column(Integer, default=0)
|
||||
unique_users = Column(Integer, default=0)
|
||||
new_users = Column(Integer, default=0)
|
||||
returning_users = Column(Integer, default=0)
|
||||
anonymous_sessions = Column(Integer, default=0)
|
||||
|
||||
# Aktywność
|
||||
total_page_views = Column(Integer, default=0)
|
||||
total_clicks = Column(Integer, default=0)
|
||||
avg_session_duration_seconds = Column(Integer, nullable=True)
|
||||
avg_pages_per_session = Column(Numeric(5, 2), nullable=True)
|
||||
|
||||
# Urządzenia
|
||||
desktop_sessions = Column(Integer, default=0)
|
||||
mobile_sessions = Column(Integer, default=0)
|
||||
tablet_sessions = Column(Integer, default=0)
|
||||
|
||||
# Engagement
|
||||
bounce_rate = Column(Numeric(5, 2), nullable=True)
|
||||
|
||||
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<AnalyticsDaily {self.date}>"
|
||||
|
||||
|
||||
class PopularPagesDaily(Base):
|
||||
"""
|
||||
Popularne strony - dzienne agregaty.
|
||||
"""
|
||||
__tablename__ = 'popular_pages_daily'
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
date = Column(Date, nullable=False, index=True)
|
||||
path = Column(String(500), nullable=False)
|
||||
page_title = Column(String(500), nullable=True)
|
||||
|
||||
views_count = Column(Integer, default=0)
|
||||
unique_visitors = Column(Integer, default=0)
|
||||
avg_time_seconds = Column(Integer, nullable=True)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint('date', 'path', name='uq_popular_pages_date_path'),
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<PopularPagesDaily {self.date} {self.path}>"
|
||||
|
||||
|
||||
# ============================================================
|
||||
# DATABASE INITIALIZATION
|
||||
# ============================================================
|
||||
|
||||
250
database/migrations/014_user_analytics.sql
Normal file
250
database/migrations/014_user_analytics.sql
Normal file
@ -0,0 +1,250 @@
|
||||
-- Migration: 014_user_analytics.sql
|
||||
-- Description: User activity tracking and analytics
|
||||
-- Created: 2026-01-13
|
||||
-- Author: Claude
|
||||
|
||||
-- ============================================================
|
||||
-- TABELE ANALITYKI UŻYTKOWNIKÓW
|
||||
-- ============================================================
|
||||
|
||||
-- Sesje użytkowników
|
||||
CREATE TABLE IF NOT EXISTS user_sessions (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
|
||||
session_id VARCHAR(100) UNIQUE NOT NULL,
|
||||
|
||||
-- Czas sesji
|
||||
started_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
ended_at TIMESTAMP,
|
||||
last_activity_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
duration_seconds INTEGER,
|
||||
|
||||
-- Urządzenie
|
||||
ip_address VARCHAR(45),
|
||||
user_agent TEXT,
|
||||
device_type VARCHAR(20), -- desktop, mobile, tablet
|
||||
browser VARCHAR(50),
|
||||
browser_version VARCHAR(20),
|
||||
os VARCHAR(50),
|
||||
os_version VARCHAR(20),
|
||||
|
||||
-- Lokalizacja (z IP)
|
||||
country VARCHAR(100),
|
||||
city VARCHAR(100),
|
||||
region VARCHAR(100),
|
||||
|
||||
-- Metryki sesji
|
||||
page_views_count INTEGER DEFAULT 0,
|
||||
clicks_count INTEGER DEFAULT 0,
|
||||
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_user_sessions_user ON user_sessions(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_sessions_started ON user_sessions(started_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_sessions_session_id ON user_sessions(session_id);
|
||||
|
||||
-- Wyświetlenia stron
|
||||
CREATE TABLE IF NOT EXISTS page_views (
|
||||
id SERIAL PRIMARY KEY,
|
||||
session_id INTEGER REFERENCES user_sessions(id) ON DELETE CASCADE,
|
||||
user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||
|
||||
-- Strona
|
||||
url VARCHAR(2000) NOT NULL,
|
||||
path VARCHAR(500) NOT NULL,
|
||||
page_title VARCHAR(500),
|
||||
referrer VARCHAR(2000),
|
||||
|
||||
-- Czas
|
||||
viewed_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
time_on_page_seconds INTEGER,
|
||||
|
||||
-- Kontekst
|
||||
company_id INTEGER REFERENCES companies(id) ON DELETE SET NULL,
|
||||
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_page_views_session ON page_views(session_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_page_views_user ON page_views(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_page_views_path ON page_views(path);
|
||||
CREATE INDEX IF NOT EXISTS idx_page_views_viewed ON page_views(viewed_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_page_views_company ON page_views(company_id);
|
||||
|
||||
-- Kliknięcia użytkowników
|
||||
CREATE TABLE IF NOT EXISTS user_clicks (
|
||||
id SERIAL PRIMARY KEY,
|
||||
session_id INTEGER REFERENCES user_sessions(id) ON DELETE CASCADE,
|
||||
page_view_id INTEGER REFERENCES page_views(id) ON DELETE CASCADE,
|
||||
user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||
|
||||
-- Element kliknięty
|
||||
element_type VARCHAR(50), -- button, link, card, nav, form
|
||||
element_id VARCHAR(100),
|
||||
element_text VARCHAR(255),
|
||||
element_class VARCHAR(500),
|
||||
target_url VARCHAR(2000),
|
||||
|
||||
-- Pozycja
|
||||
x_position INTEGER,
|
||||
y_position INTEGER,
|
||||
|
||||
clicked_at TIMESTAMP NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_user_clicks_session ON user_clicks(session_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_clicks_element ON user_clicks(element_type);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_clicks_clicked ON user_clicks(clicked_at);
|
||||
|
||||
-- Dzienne statystyki (agregowane)
|
||||
CREATE TABLE IF NOT EXISTS analytics_daily (
|
||||
id SERIAL PRIMARY KEY,
|
||||
date DATE UNIQUE NOT NULL,
|
||||
|
||||
-- Sesje
|
||||
total_sessions INTEGER DEFAULT 0,
|
||||
unique_users INTEGER DEFAULT 0,
|
||||
new_users INTEGER DEFAULT 0,
|
||||
returning_users INTEGER DEFAULT 0,
|
||||
anonymous_sessions INTEGER DEFAULT 0,
|
||||
|
||||
-- Aktywność
|
||||
total_page_views INTEGER DEFAULT 0,
|
||||
total_clicks INTEGER DEFAULT 0,
|
||||
avg_session_duration_seconds INTEGER,
|
||||
avg_pages_per_session NUMERIC(5,2),
|
||||
|
||||
-- Urządzenia
|
||||
desktop_sessions INTEGER DEFAULT 0,
|
||||
mobile_sessions INTEGER DEFAULT 0,
|
||||
tablet_sessions INTEGER DEFAULT 0,
|
||||
|
||||
-- Engagement
|
||||
bounce_rate NUMERIC(5,2), -- % sesji z 1 page view
|
||||
|
||||
updated_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_analytics_daily_date ON analytics_daily(date);
|
||||
|
||||
-- Popularne strony (dzienne)
|
||||
CREATE TABLE IF NOT EXISTS popular_pages_daily (
|
||||
id SERIAL PRIMARY KEY,
|
||||
date DATE NOT NULL,
|
||||
path VARCHAR(500) NOT NULL,
|
||||
page_title VARCHAR(500),
|
||||
|
||||
views_count INTEGER DEFAULT 0,
|
||||
unique_visitors INTEGER DEFAULT 0,
|
||||
avg_time_seconds INTEGER,
|
||||
|
||||
UNIQUE(date, path)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_popular_pages_date ON popular_pages_daily(date);
|
||||
|
||||
-- ============================================================
|
||||
-- GRANT PERMISSIONS
|
||||
-- ============================================================
|
||||
GRANT ALL ON TABLE user_sessions TO nordabiz_app;
|
||||
GRANT ALL ON TABLE page_views TO nordabiz_app;
|
||||
GRANT ALL ON TABLE user_clicks TO nordabiz_app;
|
||||
GRANT ALL ON TABLE analytics_daily TO nordabiz_app;
|
||||
GRANT ALL ON TABLE popular_pages_daily TO nordabiz_app;
|
||||
|
||||
GRANT USAGE, SELECT ON SEQUENCE user_sessions_id_seq TO nordabiz_app;
|
||||
GRANT USAGE, SELECT ON SEQUENCE page_views_id_seq TO nordabiz_app;
|
||||
GRANT USAGE, SELECT ON SEQUENCE user_clicks_id_seq TO nordabiz_app;
|
||||
GRANT USAGE, SELECT ON SEQUENCE analytics_daily_id_seq TO nordabiz_app;
|
||||
GRANT USAGE, SELECT ON SEQUENCE popular_pages_daily_id_seq TO nordabiz_app;
|
||||
|
||||
-- ============================================================
|
||||
-- TRIGGER: Update analytics_daily on new session/page_view
|
||||
-- ============================================================
|
||||
|
||||
-- Function to update daily analytics
|
||||
CREATE OR REPLACE FUNCTION update_analytics_daily()
|
||||
RETURNS TRIGGER AS $$
|
||||
DECLARE
|
||||
target_date DATE;
|
||||
BEGIN
|
||||
IF TG_TABLE_NAME = 'user_sessions' THEN
|
||||
target_date := DATE(NEW.started_at);
|
||||
ELSIF TG_TABLE_NAME = 'page_views' THEN
|
||||
target_date := DATE(NEW.viewed_at);
|
||||
ELSE
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
-- Insert or update daily stats
|
||||
INSERT INTO analytics_daily (date, total_sessions, total_page_views, updated_at)
|
||||
VALUES (target_date, 0, 0, NOW())
|
||||
ON CONFLICT (date) DO NOTHING;
|
||||
|
||||
-- Update counts based on trigger source
|
||||
IF TG_TABLE_NAME = 'user_sessions' THEN
|
||||
UPDATE analytics_daily SET
|
||||
total_sessions = total_sessions + 1,
|
||||
unique_users = (
|
||||
SELECT COUNT(DISTINCT user_id)
|
||||
FROM user_sessions
|
||||
WHERE DATE(started_at) = target_date AND user_id IS NOT NULL
|
||||
),
|
||||
anonymous_sessions = (
|
||||
SELECT COUNT(*)
|
||||
FROM user_sessions
|
||||
WHERE DATE(started_at) = target_date AND user_id IS NULL
|
||||
),
|
||||
desktop_sessions = (
|
||||
SELECT COUNT(*) FROM user_sessions
|
||||
WHERE DATE(started_at) = target_date AND device_type = 'desktop'
|
||||
),
|
||||
mobile_sessions = (
|
||||
SELECT COUNT(*) FROM user_sessions
|
||||
WHERE DATE(started_at) = target_date AND device_type = 'mobile'
|
||||
),
|
||||
tablet_sessions = (
|
||||
SELECT COUNT(*) FROM user_sessions
|
||||
WHERE DATE(started_at) = target_date AND device_type = 'tablet'
|
||||
),
|
||||
updated_at = NOW()
|
||||
WHERE date = target_date;
|
||||
|
||||
ELSIF TG_TABLE_NAME = 'page_views' THEN
|
||||
UPDATE analytics_daily SET
|
||||
total_page_views = total_page_views + 1,
|
||||
updated_at = NOW()
|
||||
WHERE date = target_date;
|
||||
END IF;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- Create triggers
|
||||
DROP TRIGGER IF EXISTS trg_update_analytics_on_session ON user_sessions;
|
||||
CREATE TRIGGER trg_update_analytics_on_session
|
||||
AFTER INSERT ON user_sessions
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_analytics_daily();
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_update_analytics_on_pageview ON page_views;
|
||||
CREATE TRIGGER trg_update_analytics_on_pageview
|
||||
AFTER INSERT ON page_views
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_analytics_daily();
|
||||
|
||||
-- ============================================================
|
||||
-- COMMENTS
|
||||
-- ============================================================
|
||||
COMMENT ON TABLE user_sessions IS 'Sesje użytkowników portalu NordaBiznes';
|
||||
COMMENT ON TABLE page_views IS 'Wyświetlenia stron przez użytkowników';
|
||||
COMMENT ON TABLE user_clicks IS 'Kliknięcia elementów na stronach';
|
||||
COMMENT ON TABLE analytics_daily IS 'Dzienne statystyki agregowane';
|
||||
COMMENT ON TABLE popular_pages_daily IS 'Popularne strony - dzienne agregaty';
|
||||
|
||||
COMMENT ON COLUMN user_sessions.device_type IS 'Typ urządzenia: desktop, mobile, tablet';
|
||||
COMMENT ON COLUMN user_sessions.duration_seconds IS 'Czas trwania sesji w sekundach';
|
||||
COMMENT ON COLUMN page_views.time_on_page_seconds IS 'Czas spędzony na stronie w sekundach';
|
||||
COMMENT ON COLUMN analytics_daily.bounce_rate IS 'Procent sesji z tylko 1 wyświetleniem strony';
|
||||
125
static/js/analytics-tracker.js
Normal file
125
static/js/analytics-tracker.js
Normal file
@ -0,0 +1,125 @@
|
||||
/**
|
||||
* NordaBiz Analytics Tracker
|
||||
* Tracks user clicks, time on page, and session heartbeats
|
||||
* Created: 2026-01-13
|
||||
*/
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
const HEARTBEAT_INTERVAL = 30000; // 30 seconds
|
||||
const TRACK_ENDPOINT = '/api/analytics/track';
|
||||
const HEARTBEAT_ENDPOINT = '/api/analytics/heartbeat';
|
||||
|
||||
let pageStartTime = Date.now();
|
||||
let currentPageViewId = null;
|
||||
|
||||
// Get page view ID from meta tag (set by Flask)
|
||||
function init() {
|
||||
const pageViewMeta = document.querySelector('meta[name="page-view-id"]');
|
||||
if (pageViewMeta && pageViewMeta.content) {
|
||||
currentPageViewId = parseInt(pageViewMeta.content, 10);
|
||||
}
|
||||
|
||||
// Start heartbeat
|
||||
setInterval(sendHeartbeat, HEARTBEAT_INTERVAL);
|
||||
|
||||
// Track clicks
|
||||
document.addEventListener('click', handleClick, true);
|
||||
|
||||
// Track time on page before leaving
|
||||
window.addEventListener('beforeunload', handleUnload);
|
||||
|
||||
// Also track visibility change (tab switch)
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||
}
|
||||
|
||||
function handleClick(e) {
|
||||
// Find the closest interactive element
|
||||
const target = e.target.closest('a, button, [data-track], input[type="submit"], .clickable, .company-card, nav a');
|
||||
if (!target) return;
|
||||
|
||||
const data = {
|
||||
type: 'click',
|
||||
page_view_id: currentPageViewId,
|
||||
element_type: getElementType(target, e),
|
||||
element_id: target.id || null,
|
||||
element_text: (target.textContent || '').trim().substring(0, 100) || null,
|
||||
element_class: target.className || null,
|
||||
target_url: target.href || target.closest('a')?.href || null,
|
||||
x: e.clientX,
|
||||
y: e.clientY
|
||||
};
|
||||
|
||||
sendTracking(data);
|
||||
}
|
||||
|
||||
function getElementType(target, e) {
|
||||
// Determine element type more precisely
|
||||
if (target.closest('nav')) return 'nav';
|
||||
if (target.closest('.company-card')) return 'company_card';
|
||||
if (target.closest('form')) return 'form';
|
||||
if (target.closest('.sidebar')) return 'sidebar';
|
||||
if (target.closest('.dropdown')) return 'dropdown';
|
||||
if (target.closest('.modal')) return 'modal';
|
||||
if (target.tagName === 'A') return 'link';
|
||||
if (target.tagName === 'BUTTON') return 'button';
|
||||
if (target.tagName === 'INPUT') return 'input';
|
||||
if (target.hasAttribute('data-track')) return target.getAttribute('data-track');
|
||||
return target.tagName.toLowerCase();
|
||||
}
|
||||
|
||||
function handleUnload() {
|
||||
if (!currentPageViewId) return;
|
||||
|
||||
const timeOnPage = Math.round((Date.now() - pageStartTime) / 1000);
|
||||
|
||||
// Use sendBeacon for reliable tracking on page exit
|
||||
const data = JSON.stringify({
|
||||
type: 'page_time',
|
||||
page_view_id: currentPageViewId,
|
||||
time_seconds: timeOnPage
|
||||
});
|
||||
|
||||
navigator.sendBeacon(TRACK_ENDPOINT, data);
|
||||
}
|
||||
|
||||
function handleVisibilityChange() {
|
||||
if (document.visibilityState === 'hidden' && currentPageViewId) {
|
||||
// Send page time when tab becomes hidden
|
||||
const timeOnPage = Math.round((Date.now() - pageStartTime) / 1000);
|
||||
sendTracking({
|
||||
type: 'page_time',
|
||||
page_view_id: currentPageViewId,
|
||||
time_seconds: timeOnPage
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function sendHeartbeat() {
|
||||
fetch(HEARTBEAT_ENDPOINT, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'same-origin'
|
||||
}).catch(function() {
|
||||
// Silently fail
|
||||
});
|
||||
}
|
||||
|
||||
function sendTracking(data) {
|
||||
fetch(TRACK_ENDPOINT, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
credentials: 'same-origin'
|
||||
}).catch(function() {
|
||||
// Silently fail
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize when DOM is ready
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
} else {
|
||||
init();
|
||||
}
|
||||
})();
|
||||
770
templates/admin/analytics_dashboard.html
Normal file
770
templates/admin/analytics_dashboard.html
Normal file
@ -0,0 +1,770 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Panel Analityki - Norda Biznes Hub{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
.admin-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: var(--spacing-xl);
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-md);
|
||||
}
|
||||
|
||||
.admin-header h1 {
|
||||
font-size: var(--font-size-3xl);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: var(--spacing-sm);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* Period Tabs */
|
||||
.period-tabs {
|
||||
display: flex;
|
||||
gap: var(--spacing-xs);
|
||||
background: white;
|
||||
padding: var(--spacing-xs);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.period-tab {
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
border: none;
|
||||
background: transparent;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--text-secondary);
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
.period-tab:hover {
|
||||
background: var(--background);
|
||||
}
|
||||
|
||||
.period-tab.active {
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Stats Grid */
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||||
gap: var(--spacing-md);
|
||||
margin-bottom: var(--spacing-xl);
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: white;
|
||||
padding: var(--spacing-lg);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-sm);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
margin: 0 auto var(--spacing-md);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.stat-icon.blue { background: #e0f2fe; color: #0284c7; }
|
||||
.stat-icon.green { background: #d1fae5; color: #059669; }
|
||||
.stat-icon.purple { background: #ede9fe; color: #7c3aed; }
|
||||
.stat-icon.orange { background: #ffedd5; color: #ea580c; }
|
||||
.stat-icon.gray { background: #f1f5f9; color: #64748b; }
|
||||
|
||||
.stat-number {
|
||||
font-size: var(--font-size-2xl);
|
||||
font-weight: 700;
|
||||
display: block;
|
||||
margin-bottom: var(--spacing-xs);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
/* Two Column Layout */
|
||||
.two-columns {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: var(--spacing-xl);
|
||||
margin-bottom: var(--spacing-xl);
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.two-columns {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* Section Card */
|
||||
.section-card {
|
||||
background: white;
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-sm);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
padding: var(--spacing-md) var(--spacing-lg);
|
||||
border-bottom: 1px solid var(--border);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.section-header h3 {
|
||||
font-size: var(--font-size-lg);
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.section-body {
|
||||
padding: var(--spacing-lg);
|
||||
}
|
||||
|
||||
/* Device Chart */
|
||||
.device-chart {
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
align-items: center;
|
||||
padding: var(--spacing-lg) 0;
|
||||
}
|
||||
|
||||
.device-item {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.device-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
margin: 0 auto var(--spacing-sm);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.device-count {
|
||||
font-size: var(--font-size-xl);
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.device-label {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.device-percent {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Tables */
|
||||
.analytics-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.analytics-table th,
|
||||
.analytics-table td {
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.analytics-table th {
|
||||
background: var(--background);
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--font-size-sm);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.analytics-table tbody tr:hover {
|
||||
background: var(--background);
|
||||
}
|
||||
|
||||
.analytics-table tbody tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.user-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.user-avatar {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.user-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.user-name {
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.user-email {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--text-secondary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.engagement-badge {
|
||||
display: inline-block;
|
||||
padding: var(--spacing-xs) var(--spacing-sm);
|
||||
border-radius: var(--radius);
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.engagement-high { background: #d1fae5; color: #059669; }
|
||||
.engagement-medium { background: #fef3c7; color: #d97706; }
|
||||
.engagement-low { background: #fee2e2; color: #dc2626; }
|
||||
|
||||
/* Page path */
|
||||
.page-path {
|
||||
font-family: monospace;
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--text-primary);
|
||||
max-width: 300px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Recent Sessions */
|
||||
.session-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-md);
|
||||
padding: var(--spacing-md);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.session-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.session-user {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.session-user-name {
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.session-anonymous {
|
||||
color: var(--text-secondary);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.session-meta {
|
||||
display: flex;
|
||||
gap: var(--spacing-md);
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.session-time {
|
||||
text-align: right;
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.session-duration {
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.session-pages {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.session-device {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-xs);
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* Empty State */
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: var(--spacing-2xl);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.empty-state svg {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
margin-bottom: var(--spacing-md);
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* Scrollable table */
|
||||
.table-scroll {
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* Back Link */
|
||||
.back-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-xs);
|
||||
color: var(--text-secondary);
|
||||
text-decoration: none;
|
||||
font-size: var(--font-size-sm);
|
||||
margin-bottom: var(--spacing-md);
|
||||
}
|
||||
|
||||
.back-link:hover {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
/* User Detail Modal */
|
||||
.user-detail-section {
|
||||
background: white;
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-sm);
|
||||
padding: var(--spacing-lg);
|
||||
margin-bottom: var(--spacing-xl);
|
||||
}
|
||||
|
||||
.user-detail-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-md);
|
||||
margin-bottom: var(--spacing-lg);
|
||||
padding-bottom: var(--spacing-lg);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.user-detail-avatar {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: 50%;
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: var(--font-size-xl);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.user-detail-info h2 {
|
||||
font-size: var(--font-size-xl);
|
||||
margin-bottom: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.user-detail-info p {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<main>
|
||||
<div class="container">
|
||||
<a href="{{ url_for('admin_panel') }}" class="back-link">
|
||||
<svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M10 12L6 8l4-4"/>
|
||||
</svg>
|
||||
Panel Admina
|
||||
</a>
|
||||
|
||||
<div class="admin-header">
|
||||
<div>
|
||||
<h1>Analityka Uzytkownikow</h1>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<div class="period-tabs">
|
||||
<a href="{{ url_for('admin_analytics', period='day') }}"
|
||||
class="period-tab {% if current_period == 'day' %}active{% endif %}">Dzis</a>
|
||||
<a href="{{ url_for('admin_analytics', period='week') }}"
|
||||
class="period-tab {% if current_period == 'week' %}active{% endif %}">7 dni</a>
|
||||
<a href="{{ url_for('admin_analytics', period='month') }}"
|
||||
class="period-tab {% if current_period == 'month' %}active{% endif %}">30 dni</a>
|
||||
<a href="{{ url_for('admin_analytics', period='all') }}"
|
||||
class="period-tab {% if current_period == 'all' %}active{% endif %}">Wszystko</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if user_detail %}
|
||||
<!-- User Detail Section -->
|
||||
<div class="user-detail-section">
|
||||
<div class="user-detail-header">
|
||||
<div class="user-detail-avatar">
|
||||
{{ user_detail.user.name[0]|upper if user_detail.user and user_detail.user.name else '?' }}
|
||||
</div>
|
||||
<div class="user-detail-info">
|
||||
<h2>{{ user_detail.user.name if user_detail.user else 'Nieznany' }}</h2>
|
||||
<p>{{ user_detail.user.email if user_detail.user else '' }}</p>
|
||||
</div>
|
||||
<a href="{{ url_for('admin_analytics', period=current_period) }}" class="btn btn-secondary">
|
||||
Zamknij
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<h3 style="margin-bottom: var(--spacing-md);">Ostatnie sesje</h3>
|
||||
<div class="table-scroll">
|
||||
<table class="analytics-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Data</th>
|
||||
<th>Czas trwania</th>
|
||||
<th>Strony</th>
|
||||
<th>Klikniecia</th>
|
||||
<th>Urzadzenie</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for s in user_detail.sessions %}
|
||||
<tr>
|
||||
<td>{{ s.started_at.strftime('%Y-%m-%d %H:%M') }}</td>
|
||||
<td>{{ (s.duration_seconds // 60)|default(0) }} min</td>
|
||||
<td>{{ s.page_views_count|default(0) }}</td>
|
||||
<td>{{ s.clicks_count|default(0) }}</td>
|
||||
<td>{{ s.device_type|default('?') }} / {{ s.browser|default('?') }}</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="5" class="empty-state">Brak sesji</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<h3 style="margin: var(--spacing-lg) 0 var(--spacing-md);">Ostatnie odwiedzone strony</h3>
|
||||
<div class="table-scroll">
|
||||
<table class="analytics-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Sciezka</th>
|
||||
<th>Czas</th>
|
||||
<th>Data</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for p in user_detail.pages %}
|
||||
<tr>
|
||||
<td class="page-path">{{ p.path }}</td>
|
||||
<td>{{ (p.time_on_page_seconds // 60)|default(0) if p.time_on_page_seconds else '-' }} min</td>
|
||||
<td>{{ p.viewed_at.strftime('%Y-%m-%d %H:%M') }}</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="3" class="empty-state">Brak danych</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Stats Cards -->
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon blue">
|
||||
<svg width="24" height="24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
|
||||
<circle cx="9" cy="7" r="4"/>
|
||||
<path d="M23 21v-2a4 4 0 0 0-3-3.87"/>
|
||||
<path d="M16 3.13a4 4 0 0 1 0 7.75"/>
|
||||
</svg>
|
||||
</div>
|
||||
<span class="stat-number">{{ stats.total_sessions }}</span>
|
||||
<span class="stat-label">Sesje</span>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon green">
|
||||
<svg width="24" height="24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/>
|
||||
<circle cx="12" cy="7" r="4"/>
|
||||
</svg>
|
||||
</div>
|
||||
<span class="stat-number">{{ stats.unique_users }}</span>
|
||||
<span class="stat-label">Unikalni uzytkownicy</span>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon purple">
|
||||
<svg width="24" height="24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z"/>
|
||||
<path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<span class="stat-number">{{ stats.total_page_views }}</span>
|
||||
<span class="stat-label">Wysw. stron</span>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon orange">
|
||||
<svg width="24" height="24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M15 15l-2 5L9 9l11 4-5 2zm0 0l5 5"/>
|
||||
</svg>
|
||||
</div>
|
||||
<span class="stat-number">{{ stats.total_clicks }}</span>
|
||||
<span class="stat-label">Klikniecia</span>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon gray">
|
||||
<svg width="24" height="24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
<polyline points="12 6 12 12 16 14"/>
|
||||
</svg>
|
||||
</div>
|
||||
<span class="stat-number">{{ (stats.avg_duration / 60)|round(1) if stats.avg_duration else 0 }} min</span>
|
||||
<span class="stat-label">Sr. czas sesji</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="two-columns">
|
||||
<!-- Device Breakdown -->
|
||||
<div class="section-card">
|
||||
<div class="section-header">
|
||||
<h3>Urzadzenia</h3>
|
||||
</div>
|
||||
<div class="section-body">
|
||||
<div class="device-chart">
|
||||
<div class="device-item">
|
||||
<div class="device-icon">
|
||||
<svg width="48" height="48" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
<rect x="4" y="6" width="40" height="28" rx="2"/>
|
||||
<line x1="4" y1="38" x2="44" y2="38"/>
|
||||
<line x1="18" y1="34" x2="30" y2="34"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="device-count">{{ device_stats.get('desktop', 0) }}</div>
|
||||
<div class="device-label">Desktop</div>
|
||||
{% set total_devices = device_stats.get('desktop', 0) + device_stats.get('mobile', 0) + device_stats.get('tablet', 0) %}
|
||||
<div class="device-percent">
|
||||
{{ ((device_stats.get('desktop', 0) / total_devices * 100)|round(0)|int if total_devices > 0 else 0) }}%
|
||||
</div>
|
||||
</div>
|
||||
<div class="device-item">
|
||||
<div class="device-icon">
|
||||
<svg width="48" height="48" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
<rect x="14" y="4" width="20" height="40" rx="2"/>
|
||||
<line x1="20" y1="40" x2="28" y2="40"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="device-count">{{ device_stats.get('mobile', 0) }}</div>
|
||||
<div class="device-label">Mobile</div>
|
||||
<div class="device-percent">
|
||||
{{ ((device_stats.get('mobile', 0) / total_devices * 100)|round(0)|int if total_devices > 0 else 0) }}%
|
||||
</div>
|
||||
</div>
|
||||
<div class="device-item">
|
||||
<div class="device-icon">
|
||||
<svg width="48" height="48" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
<rect x="6" y="8" width="36" height="28" rx="2"/>
|
||||
<line x1="18" y1="40" x2="30" y2="40"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="device-count">{{ device_stats.get('tablet', 0) }}</div>
|
||||
<div class="device-label">Tablet</div>
|
||||
<div class="device-percent">
|
||||
{{ ((device_stats.get('tablet', 0) / total_devices * 100)|round(0)|int if total_devices > 0 else 0) }}%
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Popular Pages -->
|
||||
<div class="section-card">
|
||||
<div class="section-header">
|
||||
<h3>Popularne strony</h3>
|
||||
</div>
|
||||
<div class="table-scroll">
|
||||
<table class="analytics-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Sciezka</th>
|
||||
<th>Wysw.</th>
|
||||
<th>Unik.</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for page in popular_pages[:10] %}
|
||||
<tr>
|
||||
<td class="page-path" title="{{ page.path }}">{{ page.path }}</td>
|
||||
<td>{{ page.views }}</td>
|
||||
<td>{{ page.unique_users }}</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="3" class="empty-state">Brak danych</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- User Rankings -->
|
||||
<div class="section-card" style="margin-bottom: var(--spacing-xl);">
|
||||
<div class="section-header">
|
||||
<h3>Ranking uzytkownikow wg aktywnosci</h3>
|
||||
</div>
|
||||
<div class="table-scroll">
|
||||
<table class="analytics-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Uzytkownik</th>
|
||||
<th>Sesje</th>
|
||||
<th>Strony</th>
|
||||
<th>Klikniecia</th>
|
||||
<th>Czas</th>
|
||||
<th>Zaangazowanie</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for user in user_rankings %}
|
||||
<tr>
|
||||
<td>
|
||||
<div class="user-cell">
|
||||
<div class="user-avatar">{{ user.name[0]|upper if user.name else '?' }}</div>
|
||||
<div class="user-info">
|
||||
<div class="user-name">{{ user.name }}</div>
|
||||
<div class="user-email">{{ user.email }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>{{ user.sessions }}</td>
|
||||
<td>{{ user.page_views|default(0) }}</td>
|
||||
<td>{{ user.clicks|default(0) }}</td>
|
||||
<td>{{ ((user.total_time or 0) / 60)|round(0)|int }} min</td>
|
||||
<td>
|
||||
{% set engagement = (user.page_views|default(0)) + (user.clicks|default(0)) * 2 + ((user.total_time or 0) / 120)|int %}
|
||||
{% if engagement >= 50 %}
|
||||
<span class="engagement-badge engagement-high">Wysoki ({{ engagement }})</span>
|
||||
{% elif engagement >= 20 %}
|
||||
<span class="engagement-badge engagement-medium">Sredni ({{ engagement }})</span>
|
||||
{% else %}
|
||||
<span class="engagement-badge engagement-low">Niski ({{ engagement }})</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<a href="{{ url_for('admin_analytics', period=current_period, user_id=user.id) }}"
|
||||
style="color: var(--primary); text-decoration: none;">
|
||||
Szczegoly
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="7" class="empty-state">
|
||||
<svg width="64" height="64" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
<circle cx="32" cy="32" r="28"/>
|
||||
<path d="M24 28h16"/>
|
||||
<path d="M24 36h8"/>
|
||||
</svg>
|
||||
<p>Brak danych o uzytkownikach w wybranym okresie</p>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recent Sessions -->
|
||||
<div class="section-card">
|
||||
<div class="section-header">
|
||||
<h3>Ostatnie sesje</h3>
|
||||
</div>
|
||||
<div class="table-scroll" style="max-height: 500px;">
|
||||
{% for s in recent_sessions %}
|
||||
<div class="session-item">
|
||||
<div class="session-user">
|
||||
{% if s.user %}
|
||||
<div class="session-user-name">{{ s.user.name }}</div>
|
||||
<div style="font-size: var(--font-size-sm); color: var(--text-secondary);">{{ s.user.email }}</div>
|
||||
{% else %}
|
||||
<div class="session-anonymous">Niezalogowany</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="session-device">
|
||||
{% if s.device_type == 'mobile' %}
|
||||
<svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="4" y="1" width="8" height="14" rx="1"/>
|
||||
</svg>
|
||||
{% elif s.device_type == 'tablet' %}
|
||||
<svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="2" y="2" width="12" height="12" rx="1"/>
|
||||
</svg>
|
||||
{% else %}
|
||||
<svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="1" y="2" width="14" height="10" rx="1"/>
|
||||
<line x1="4" y1="14" x2="12" y2="14"/>
|
||||
</svg>
|
||||
{% endif %}
|
||||
{{ s.browser|default('?') }}
|
||||
</div>
|
||||
<div class="session-time">
|
||||
<div class="session-duration">{{ (s.duration_seconds // 60)|default(0) }} min</div>
|
||||
<div class="session-pages">{{ s.page_views_count|default(0) }} stron</div>
|
||||
</div>
|
||||
<div style="font-size: var(--font-size-sm); color: var(--text-secondary); min-width: 100px; text-align: right;">
|
||||
{{ s.started_at.strftime('%d.%m %H:%M') }}
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="empty-state">
|
||||
<svg width="64" height="64" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
<circle cx="32" cy="32" r="28"/>
|
||||
<path d="M32 20v12"/>
|
||||
<path d="M32 40h.01"/>
|
||||
</svg>
|
||||
<p>Brak sesji w wybranym okresie</p>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
{% endblock %}
|
||||
@ -6,6 +6,7 @@
|
||||
<meta name="description" content="Norda Biznes Hub - katalog firm członkowskich stowarzyszenia Norda Biznes">
|
||||
<meta name="author" content="Norda Biznes">
|
||||
<meta name="robots" content="index, follow">
|
||||
<meta name="page-view-id" content="{{ page_view_id|default('') }}">
|
||||
|
||||
<title>{% block title %}Norda Biznes Hub{% endblock %}</title>
|
||||
|
||||
@ -892,6 +893,9 @@
|
||||
|
||||
{% block extra_css %}{% endblock %}
|
||||
</style>
|
||||
|
||||
<!-- Analytics Tracker -->
|
||||
<script src="{{ url_for('static', filename='js/analytics-tracker.js') }}" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<!-- Header -->
|
||||
|
||||
Loading…
Reference in New Issue
Block a user