feat(web): high-visibility support modal, prompts, and supporter UX

Add a support modal with usage stats, tier and license links, share control, and offline-safe outbound CTAs. Surface support from the header, sidebar, user menu, dashboard card, and settings "Support & Community" section without hiding entry points when a supporter license is active.

Introduce UsageStatsService and a persisted users.support_stats_reports_generated counter incremented on key report exports and custom report views. Add SupportPromptService for session-scoped soft toasts (after export, dashboard milestones, long session via POST /donate/request-soft-prompt).

Wire consent-aware track_event names support.* and mirror funnel rows in DonationInteraction; fix has_recent_donation_click to treat link_clicked as a recent click. Document events and SUPPORT_* / migration notes in docs.

Tests: tests/test_support_services.py for prompt and usage stats behavior.
This commit is contained in:
Dries Peeters
2026-04-15 10:55:37 +02:00
parent 96955aee62
commit b0dde80ba9
29 changed files with 1063 additions and 80 deletions
+3
View File
@@ -134,6 +134,9 @@ class Config:
# Support & Purchase Key page URL (for links to purchase a key to hide donate UI)
SUPPORT_PURCHASE_URL = os.getenv("SUPPORT_PURCHASE_URL", "https://timetracker.drytrix.com/support.html").strip()
SUPPORT_PORTAL_BASE = os.getenv("SUPPORT_PORTAL_BASE", "https://timetracker.drytrix.com").strip()
# Optional one-line social proof for support modal (empty = omit block)
SUPPORT_SOCIAL_PROOF_TEXT = os.getenv("SUPPORT_SOCIAL_PROOF_TEXT", "").strip()
# Backup settings
BACKUP_RETENTION_DAYS = int(os.getenv("BACKUP_RETENTION_DAYS", 30))
+59
View File
@@ -0,0 +1,59 @@
"""Non-translated support/checkout configuration (URLs, numeric defaults)."""
from __future__ import annotations
import os
from typing import Any, Dict
def get_support_portal_base(config: Dict[str, Any] | Any) -> str:
"""Marketing site base; defaults to drytrix TimeTracker domain."""
if hasattr(config, "get"):
raw = (config.get("SUPPORT_PORTAL_BASE") or "").strip()
else:
raw = ""
if not raw:
raw = os.getenv("SUPPORT_PORTAL_BASE", "https://timetracker.drytrix.com").strip()
return raw.rstrip("/")
def build_support_checkout_urls(config: Dict[str, Any] | Any) -> Dict[str, str]:
"""
Per-tier outbound URLs. Unset env vars fall back to SUPPORT_PURCHASE_URL so checkout stays one hop.
"""
if hasattr(config, "get"):
purchase = (config.get("SUPPORT_PURCHASE_URL") or "").strip()
else:
purchase = ""
if not purchase:
purchase = os.getenv(
"SUPPORT_PURCHASE_URL", "https://timetracker.drytrix.com/support.html"
).strip()
def _tier(env_name: str) -> str:
v = os.getenv(env_name, "").strip()
return v or purchase
return {
"eur5": _tier("SUPPORT_DONATE_EUR5_URL"),
"eur10": _tier("SUPPORT_DONATE_EUR10_URL"),
"eur25": _tier("SUPPORT_DONATE_EUR25_URL"),
"license": purchase,
}
def get_long_session_minutes() -> int:
try:
return max(30, int(os.getenv("SUPPORT_LONG_SESSION_MINUTES", "120")))
except ValueError:
return 120
def get_social_proof_text(config: Dict[str, Any] | Any) -> str:
if hasattr(config, "get"):
t = (config.get("SUPPORT_SOCIAL_PROOF_TEXT") or "").strip()
else:
t = ""
if not t:
t = os.getenv("SUPPORT_SOCIAL_PROOF_TEXT", "").strip()
return t
+9 -9
View File
@@ -5,6 +5,8 @@ Canonical interaction_type values (funnel):
- banner_impression: support banner was shown (measured client-side)
- banner_dismissed: user dismissed the banner
- link_clicked: user clicked a support CTA (donate or key; segment by source)
- support_modal_opened, support_donation_clicked, support_license_clicked: support modal funnel
- support_prompt_shown, support_prompt_dismissed: soft prompt funnel
Canonical source values for CTR per placement:
- header, banner, banner_bmc, banner_paypal, banner_key
@@ -76,17 +78,15 @@ class DonationInteraction(db.Model):
@staticmethod
def has_recent_donation_click(user_id: int, days: int = 30) -> bool:
"""Check if user clicked donation link in last N days"""
"""Check if user clicked a donation/support outbound link in last N days."""
cutoff = datetime.utcnow() - timedelta(days=days)
interaction_types = ("link_clicked", "banner_clicked")
return (
DonationInteraction.query.filter_by(user_id=user_id, interaction_type="banner_clicked")
.filter(DonationInteraction.created_at >= cutoff)
.first()
is not None
) or (
DonationInteraction.query.filter_by(user_id=user_id, interaction_type="link_clicked")
.filter(DonationInteraction.created_at >= cutoff)
.first()
DonationInteraction.query.filter(
DonationInteraction.user_id == user_id,
DonationInteraction.interaction_type.in_(interaction_types),
DonationInteraction.created_at >= cutoff,
).first()
is not None
)
+3
View File
@@ -144,6 +144,9 @@ class User(UserMixin, db.Model):
ui_show_kiosk = db.Column(db.Boolean, default=True, nullable=False) # Show/hide Kiosk Mode
ui_show_donate = db.Column(db.Boolean, default=True, nullable=False) # Show/hide donate/support UI
# Support UX: count of report generations (exports + custom report views) for stats in support modal
support_stats_reports_generated = db.Column(db.Integer, default=0, nullable=False)
# Relationships
time_entries = db.relationship("TimeEntry", backref="user", lazy="dynamic", cascade="all, delete-orphan")
project_costs = db.relationship("ProjectCost", backref="user", lazy="dynamic", cascade="all, delete-orphan")
+3
View File
@@ -14,6 +14,7 @@ from app.models import Client, Expense, Invoice, Project, SavedReportView, Task,
from app.services.unpaid_hours_service import UnpaidHoursService
from app.utils.db import safe_commit
from app.utils.module_helpers import module_enabled
from app.utils.support_report_generation import record_report_generation_for_current_user
custom_reports_bp = Blueprint("custom_reports", __name__)
@@ -203,11 +204,13 @@ def view_custom_report(view_id):
# Check if iterative report generation is enabled
if saved_view.iterative_report_generation and saved_view.iterative_custom_field_name:
# Generate reports for each custom field value
record_report_generation_for_current_user()
return _generate_iterative_reports(saved_view, config, current_user.id)
# Generate single report data based on config
report_data = generate_report_data(config, current_user.id)
record_report_generation_for_current_user()
return render_template("reports/custom_view.html", saved_view=saved_view, config=config, report_data=report_data)
+113 -6
View File
@@ -14,10 +14,11 @@ from flask import (
session,
url_for,
)
from flask_babel import gettext as _
from flask_login import current_user, login_required
from sqlalchemy import text
from app import db, track_page_view
from app import db, track_event, track_page_view
from app.models import Activity, Client, Project, Settings, TimeEntry, TimeEntryTemplate, User, WeeklyTimeGoal
from app.models.time_entry import local_now
from app.utils.license_utils import is_license_activated
@@ -190,10 +191,12 @@ def dashboard():
timer_stopped_toast["time_entries_url"] = url_for("timer.time_entries_overview")
# Get user stats for smart banner and donation widget
support_banner_suppressed_dashboard = False
try:
from app.models import DonationInteraction
user_stats = DonationInteraction.get_user_engagement_metrics(current_user.id)
support_banner_suppressed_dashboard = DonationInteraction.has_recent_donation_click(current_user.id, days=30)
except Exception:
# Fallback if table doesn't exist yet
days_since_signup = (datetime.utcnow() - current_user.created_at).days if current_user.created_at else 0
@@ -209,11 +212,40 @@ def dashboard():
time_entries_count = user_stats.get("time_entries_count", 0)
total_hours = user_stats.get("total_hours", 0.0)
# Optional support reminder: show at most once per session for unlicensed instances
settings_obj = Settings.get_settings()
show_support_reminder = not is_license_activated(settings_obj) and not session.get("support_reminder_shown", False)
if show_support_reminder:
session["support_reminder_shown"] = True
from app.services.support_prompt_service import SupportPromptService
from app.services.usage_stats_service import UsageStatsService
usage_support_stats = UsageStatsService.get_for_user(current_user.id, month_hours=float(month_hours or 0))
is_supporter = is_license_activated(settings_obj)
ui_show_donate = getattr(current_user, "ui_show_donate", True)
support_dashboard_prompt = SupportPromptService.pick_dashboard_prompt(
session,
user_stats,
ui_show_donate=ui_show_donate,
is_supporter=is_supporter,
support_banner_suppressed=support_banner_suppressed_dashboard,
today_hours=float(today_hours or 0),
)
if support_dashboard_prompt:
SupportPromptService.mark_prompt_shown(session, support_dashboard_prompt["variant"])
v = support_dashboard_prompt.get("variant")
if v == SupportPromptService.VARIANT_SEVEN_DAY:
support_dashboard_prompt = {
**support_dashboard_prompt,
"message": _(
"You have been using TimeTracker for a week or more. If it fits your workflow, "
"consider supporting continued development."
),
}
elif v == SupportPromptService.VARIANT_ACTIVE_TODAY:
support_dashboard_prompt = {
**support_dashboard_prompt,
"message": _(
"You have tracked a solid amount of time today. If TimeTracker makes your day easier, "
"you can support the project in a click."
),
}
# Prepare template data
template_data = {
@@ -246,7 +278,9 @@ def dashboard():
"time_entries_count": time_entries_count, # For donation widget
"total_hours": total_hours, # For donation widget
"timer_stopped_toast": timer_stopped_toast,
"show_support_reminder": show_support_reminder,
"usage_support_stats": usage_support_stats,
"support_dashboard_prompt": support_dashboard_prompt,
"is_supporter_instance": is_supporter,
}
return render_template("main/dashboard.html", **template_data)
@@ -398,6 +432,79 @@ def track_support_impression():
return jsonify({"success": True, "note": "Tracking unavailable"})
@main_bp.route("/donate/request-soft-prompt", methods=["POST"])
@login_required
def request_soft_support_prompt():
"""Authorize a single long-session soft prompt (session rules enforced server-side)."""
from app.models import DonationInteraction, Settings
from app.services.support_prompt_service import SupportPromptService
data = request.get_json() or {}
kind = (data.get("kind") or "long_session").strip()
if kind != "long_session":
return jsonify({"show": False})
settings_obj = Settings.get_settings()
is_supporter = is_license_activated(settings_obj)
ui_show = getattr(current_user, "ui_show_donate", True)
try:
suppressed = DonationInteraction.has_recent_donation_click(current_user.id, days=30)
except Exception:
suppressed = False
if not SupportPromptService.long_session_prompt_allowed(
session,
ui_show_donate=ui_show,
is_supporter=is_supporter,
support_banner_suppressed=suppressed,
):
return jsonify({"show": False})
SupportPromptService.mark_prompt_shown(session, SupportPromptService.VARIANT_LONG_SESSION)
return jsonify({"show": True, "variant": "long_session"})
@main_bp.route("/donate/track-support-event", methods=["POST"])
@login_required
def track_support_event():
"""Telemetry + DonationInteraction funnel for support UI (best-effort)."""
from app.models import DonationInteraction
data = request.get_json() or {}
event = (data.get("event") or "").strip()
variant = data.get("variant")
source = (data.get("source") or "support_ui").strip()
event_map = {
"modal_opened": ("support.modal_opened", "support_modal_opened"),
"donation_clicked": ("support.donation_clicked", "support_donation_clicked"),
"license_clicked": ("support.license_clicked", "support_license_clicked"),
"prompt_shown": ("support.prompt_shown", "support_prompt_shown"),
"prompt_dismissed": ("support.prompt_dismissed", "support_prompt_dismissed"),
}
if event not in event_map:
return jsonify({"success": False, "error": "unknown event"}), 400
analytics_name, interaction_type = event_map[event]
props = {"variant": variant, "source": source}
track_event(current_user.id, analytics_name, props)
try:
metrics = DonationInteraction.get_user_engagement_metrics(current_user.id)
DonationInteraction.record_interaction(
user_id=current_user.id,
interaction_type=interaction_type,
source=source,
user_metrics=metrics,
variant=variant,
)
except Exception:
pass
return jsonify({"success": True})
@main_bp.route("/debug/i18n")
@login_required
def debug_i18n():
+6
View File
@@ -25,6 +25,7 @@ from app.models import (
)
from app.repositories import TimeEntryRepository
from app.services.scheduled_report_service import ScheduledReportService
from app.utils.support_report_generation import record_report_generation_for_current_user
from app.utils.excel_export import create_project_report_excel, create_time_entries_excel
from app.utils.posthog_monitoring import track_error, track_export_performance, track_validation_error
@@ -513,6 +514,7 @@ def export_csv():
# Don't let tracking errors break the export
pass
record_report_generation_for_current_user()
return send_file(io.BytesIO(csv_content), mimetype="text/csv", as_attachment=True, download_name=filename)
except Exception:
current_app.logger.exception("CSV export failed (reports.export_csv)")
@@ -569,6 +571,7 @@ def export_summary_pdf():
current_app.logger.warning("Summary report PDF export failed: %s", e, exc_info=True)
flash(_("PDF export failed: %(error)s", error=str(e)), "error")
return redirect(url_for("reports.summary_report"))
record_report_generation_for_current_user()
filename = f"summary_report_{datetime.utcnow().strftime('%Y%m%d')}.pdf"
return send_file(
io.BytesIO(pdf_bytes),
@@ -977,6 +980,7 @@ def time_entries_export_excel():
"export.excel",
{"export_type": "time_entries_report", "num_rows": len(entries)},
)
record_report_generation_for_current_user()
return send_file(
output,
mimetype="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
@@ -1039,6 +1043,7 @@ def time_entries_export_csv():
writer.writerow(row)
output.seek(0)
filename = f"time_entries_report_{start_date}_to_{end_date}.csv"
record_report_generation_for_current_user()
return send_file(
io.BytesIO(output.getvalue().encode("utf-8")),
mimetype="text/csv",
@@ -1118,6 +1123,7 @@ def export_excel():
{"export_type": "time_entries", "num_rows": len(entries), "date_range_days": (end_dt - start_dt).days},
)
record_report_generation_for_current_user()
return send_file(
output,
mimetype="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
+1 -1
View File
@@ -218,7 +218,7 @@ def settings():
@user_bp.route("/settings/license", methods=["GET", "POST"])
@login_required
def license():
"""License management page: show status, enter key, validate (sets donate_ui_hidden for instance)."""
"""License management: supporter key validation (sets donate_ui_hidden / supporter instance flag)."""
settings_obj = Settings.get_settings()
if request.method == "POST":
if is_license_activated(settings_obj):
+127
View File
@@ -0,0 +1,127 @@
"""Rules for soft, non-blocking support prompts (session-scoped)."""
from __future__ import annotations
from typing import Any, Dict, Optional
class SupportPromptService:
"""At most one soft prompt per session; respect supporter and donation-click cooldown."""
SESSION_SOFT_PROMPT_CONSUMED = "support_soft_prompt_consumed"
SESSION_PROMPT_TRIGGER = "support_prompt_trigger"
SESSION_SEVEN_DAY_OFFERED = "support_prompt_7d_offered"
SESSION_ACTIVE_DAY_OFFERED = "support_prompt_active_day_offered"
VARIANT_AFTER_REPORT = "after_report"
VARIANT_SEVEN_DAY = "seven_day"
VARIANT_ACTIVE_TODAY = "active_today"
VARIANT_LONG_SESSION = "long_session"
@staticmethod
def _base_eligible(
session: Dict[str, Any],
*,
ui_show_donate: bool,
is_supporter: bool,
support_banner_suppressed: bool,
) -> bool:
if not ui_show_donate:
return False
if is_supporter:
return False
if support_banner_suppressed:
return False
if session.get(SupportPromptService.SESSION_SOFT_PROMPT_CONSUMED):
return False
return True
@staticmethod
def consume_layout_prompt(
session: Dict[str, Any],
*,
ui_show_donate: bool,
is_supporter: bool,
support_banner_suppressed: bool,
) -> Optional[Dict[str, str]]:
"""
If the user just finished a report export, show one after-report toast on next full page load.
Marks the session as having shown a soft prompt when returning a payload.
"""
if not SupportPromptService._base_eligible(
session,
ui_show_donate=ui_show_donate,
is_supporter=is_supporter,
support_banner_suppressed=support_banner_suppressed,
):
return None
trigger = session.get(SupportPromptService.SESSION_PROMPT_TRIGGER)
if trigger != SupportPromptService.VARIANT_AFTER_REPORT:
return None
session.pop(SupportPromptService.SESSION_PROMPT_TRIGGER, None)
session[SupportPromptService.SESSION_SOFT_PROMPT_CONSUMED] = True
return {"variant": SupportPromptService.VARIANT_AFTER_REPORT, "source": "after_report"}
@staticmethod
def pick_dashboard_prompt(
session: Dict[str, Any],
user_stats: Dict[str, Any],
*,
ui_show_donate: bool,
is_supporter: bool,
support_banner_suppressed: bool,
today_hours: float,
) -> Optional[Dict[str, str]]:
"""
Eligible only on dashboard: milestone (7+ days since signup) or active tracking day.
Does not consume session slot until caller records prompt shown (caller should set consumed).
"""
if not SupportPromptService._base_eligible(
session,
ui_show_donate=ui_show_donate,
is_supporter=is_supporter,
support_banner_suppressed=support_banner_suppressed,
):
return None
# After-report takes priority; leave trigger for layout pass
if session.get(SupportPromptService.SESSION_PROMPT_TRIGGER) == SupportPromptService.VARIANT_AFTER_REPORT:
return None
days = int(user_stats.get("days_since_signup") or 0)
if days >= 7 and not session.get(SupportPromptService.SESSION_SEVEN_DAY_OFFERED):
return {"variant": SupportPromptService.VARIANT_SEVEN_DAY, "source": "dashboard"}
if float(today_hours or 0) >= 4.0 and not session.get(SupportPromptService.SESSION_ACTIVE_DAY_OFFERED):
return {"variant": SupportPromptService.VARIANT_ACTIVE_TODAY, "source": "dashboard"}
return None
@staticmethod
def mark_prompt_shown(session: Dict[str, Any], variant: str) -> None:
session[SupportPromptService.SESSION_SOFT_PROMPT_CONSUMED] = True
if variant == SupportPromptService.VARIANT_SEVEN_DAY:
session[SupportPromptService.SESSION_SEVEN_DAY_OFFERED] = True
elif variant == SupportPromptService.VARIANT_ACTIVE_TODAY:
session[SupportPromptService.SESSION_ACTIVE_DAY_OFFERED] = True
elif variant == SupportPromptService.VARIANT_LONG_SESSION:
pass
@staticmethod
def long_session_prompt_allowed(
session: Dict[str, Any],
*,
ui_show_donate: bool,
is_supporter: bool,
support_banner_suppressed: bool,
) -> bool:
"""JSON endpoint: allow long-session nudge only if no prompt consumed yet this session."""
if not SupportPromptService._base_eligible(
session,
ui_show_donate=ui_show_donate,
is_supporter=is_supporter,
support_banner_suppressed=support_banner_suppressed,
):
return False
if session.get(SupportPromptService.SESSION_PROMPT_TRIGGER) == SupportPromptService.VARIANT_AFTER_REPORT:
return False
return True
+56
View File
@@ -0,0 +1,56 @@
"""Aggregated usage stats for support modal, dashboard widget, and prompts."""
from __future__ import annotations
from typing import Any, Dict, Optional
from app import db
class UsageStatsService:
"""Read/write lightweight counters and engagement metrics for support UI."""
@staticmethod
def get_for_user(user_id: int, month_hours: Optional[float] = None) -> Dict[str, Any]:
from app.models import DonationInteraction, User
base = DonationInteraction.get_user_engagement_metrics(user_id) or {}
reports_count = 0
try:
u = db.session.get(User, user_id)
if u is not None:
reports_count = int(getattr(u, "support_stats_reports_generated", 0) or 0)
except Exception:
reports_count = 0
out = {
"total_hours": float(base.get("total_hours") or 0.0),
"time_entries_count": int(base.get("time_entries_count") or 0),
"days_since_signup": int(base.get("days_since_signup") or 0),
"reports_generated_count": reports_count,
}
if month_hours is not None:
out["month_hours"] = float(month_hours)
return out
@staticmethod
def increment_reports_generated(user_id: int) -> None:
"""Persist +1 report generation (export or custom report view). Never raises."""
if not user_id:
return
try:
from sqlalchemy import text
db.session.execute(
text(
"UPDATE users SET support_stats_reports_generated = "
"COALESCE(support_stats_reports_generated, 0) + 1 WHERE id = :uid"
),
{"uid": user_id},
)
db.session.commit()
except Exception:
try:
db.session.rollback()
except Exception:
pass
+275
View File
@@ -0,0 +1,275 @@
/**
* Support modal, header pulse, offline-aware outbound links, soft prompts.
* Copy lives in Jinja / JSON (support_ui_json); this file is behavior only.
*/
(function () {
'use strict';
function getCsrfToken() {
var meta = document.querySelector('meta[name="csrf-token"]');
return meta ? meta.getAttribute('content') || '' : '';
}
function parseSupportConfig() {
var el = document.getElementById('support-ui-bootstrap');
if (!el || !el.textContent) return null;
try {
return JSON.parse(el.textContent);
} catch (e) {
return null;
}
}
function postTrack(cfg, event, extra) {
if (!cfg || !cfg.trackUrl) return;
var body = Object.assign({ event: event }, extra || {});
fetch(cfg.trackUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': getCsrfToken()
},
body: JSON.stringify(body),
credentials: 'same-origin'
}).catch(function () {});
}
function applyOfflineState(cfg) {
var offlineEl = document.getElementById('supportModalOffline');
var tierBtns = document.querySelectorAll('a.support-tier-btn');
var online = typeof navigator !== 'undefined' && navigator.onLine;
if (!offlineEl) return;
if (!online && cfg && cfg.i18n && cfg.i18n.offlineNote) {
offlineEl.textContent = cfg.i18n.offlineNote;
offlineEl.classList.remove('hidden');
tierBtns.forEach(function (a) {
a.setAttribute('tabindex', '-1');
a.classList.add('pointer-events-none', 'opacity-50');
});
} else {
offlineEl.classList.add('hidden');
tierBtns.forEach(function (a) {
a.removeAttribute('tabindex');
a.classList.remove('pointer-events-none', 'opacity-50');
});
}
}
function wireTierLinks(cfg) {
if (!cfg || !cfg.urls) return;
document.querySelectorAll('a.support-tier-btn[data-support-tier]').forEach(function (a) {
var key = a.getAttribute('data-support-tier');
if (key && cfg.urls[key]) {
a.href = cfg.urls[key];
}
a.addEventListener('click', function () {
postTrack(cfg, 'donation_clicked', { variant: key, source: 'support_modal' });
});
});
var lic = document.querySelector('a[data-support-tier="license"]');
if (lic) {
lic.addEventListener('click', function () {
postTrack(cfg, 'license_clicked', { source: 'support_modal' });
});
}
}
function syncStatsFromConfig(cfg) {
if (!cfg || !cfg.stats) return;
var h = document.getElementById('supportStatHours');
var e = document.getElementById('supportStatEntries');
var r = document.getElementById('supportStatReports');
if (h) h.textContent = Number(cfg.stats.total_hours || 0).toFixed(1);
if (e) e.textContent = String(cfg.stats.time_entries_count != null ? cfg.stats.time_entries_count : 0);
if (r) r.textContent = String(cfg.stats.reports_generated_count != null ? cfg.stats.reports_generated_count : 0);
var social = document.getElementById('supportSocialLine');
if (social && cfg.socialProofLine) {
social.textContent = cfg.socialProofLine;
}
}
function openSupportModal() {
var modal = document.getElementById('supportModal');
if (!modal) return;
var cfg = parseSupportConfig();
syncStatsFromConfig(cfg);
applyOfflineState(cfg);
modal.classList.remove('hidden');
modal.setAttribute('aria-hidden', 'false');
if (cfg) postTrack(cfg, 'modal_opened', { source: 'support_modal' });
}
function closeSupportModal() {
var modal = document.getElementById('supportModal');
if (!modal) return;
modal.classList.add('hidden');
modal.setAttribute('aria-hidden', 'true');
}
window.openSupportModal = openSupportModal;
window.closeSupportModal = closeSupportModal;
function showSoftToast(cfg, message, variant, source) {
if (!window.toastManager || typeof window.toastManager.show !== 'function') return;
window.toastManager.show({
message: message,
type: 'info',
duration: 8000,
dismissible: true,
actionLink: '__support_modal__',
actionLabel: (cfg && cfg.i18n && cfg.i18n.supportAction) || 'Support'
});
postTrack(cfg, 'prompt_shown', { variant: variant, source: source || 'toast' });
}
function maybeLongSessionPrompt(cfg) {
if (!cfg || !cfg.sessionStartedAt || !cfg.softPromptUrl) return;
var mins = Number(cfg.longSessionMinutes) || 120;
var started = Date.parse(cfg.sessionStartedAt);
if (!started) return;
function check() {
var elapsedMin = (Date.now() - started) / 60000;
if (elapsedMin < mins) return;
clearInterval(timer);
fetch(cfg.softPromptUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': getCsrfToken()
},
body: JSON.stringify({ kind: 'long_session' }),
credentials: 'same-origin'
})
.then(function (r) {
return r.json();
})
.then(function (data) {
if (!data || !data.show) return;
var msg =
(cfg.i18n && cfg.i18n.longSessionToast) ||
'If TimeTracker helps your day, consider supporting its development.';
var act = (cfg.i18n && cfg.i18n.supportAction) || 'Support';
if (window.toastManager && typeof window.toastManager.show === 'function') {
window.toastManager.show({
message: msg,
type: 'info',
duration: 9000,
dismissible: true,
actionLink: '__support_modal__',
actionLabel: act
});
}
postTrack(cfg, 'prompt_shown', { variant: 'long_session', source: 'long_session_timer' });
})
.catch(function () {});
}
var timer = setInterval(check, 60000);
setTimeout(check, 5000);
}
function layoutPromptFromConfig(cfg) {
if (!cfg || !cfg.layoutPrompt || !cfg.layoutPrompt.message) return;
showSoftToast(cfg, cfg.layoutPrompt.message, cfg.layoutPrompt.variant || 'after_report', 'layout');
}
function dashboardPrompt() {
var cfg = parseSupportConfig();
var raw = window.__TT_DASHBOARD_SUPPORT_PROMPT;
if (!cfg || !raw || !raw.message) return;
showSoftToast(cfg, raw.message, raw.variant || 'dashboard', raw.source || 'dashboard');
}
function headerPulse(btn) {
if (!btn) return;
try {
if (sessionStorage.getItem('tt_support_header_pulse_done')) return;
btn.classList.add('animate-pulse', 'ring-2', 'ring-amber-400/60');
setTimeout(function () {
btn.classList.remove('animate-pulse', 'ring-2', 'ring-amber-400/60');
}, 2400);
sessionStorage.setItem('tt_support_header_pulse_done', '1');
} catch (e) {}
}
function wireModalDom(cfg) {
var modal = document.getElementById('supportModal');
if (!modal) return;
modal.querySelectorAll('[data-support-modal-close], [data-support-modal-overlay]').forEach(function (el) {
el.addEventListener('click', function () {
closeSupportModal();
});
});
document.addEventListener('keydown', function (ev) {
if (ev.key === 'Escape' && !modal.classList.contains('hidden')) {
closeSupportModal();
}
});
var shareBtn = document.getElementById('supportShareBtn');
if (shareBtn && cfg && cfg.shareUrl) {
shareBtn.addEventListener('click', function () {
var url = cfg.shareUrl;
if (navigator.share) {
navigator
.share({
title: document.title,
url: url
})
.catch(function () {});
} else if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(url).then(
function () {
if (window.toastManager) {
window.toastManager.show(
(cfg.i18n && cfg.i18n.shareSuccess) || 'Copied',
'success'
);
}
},
function () {
if (window.toastManager) {
window.toastManager.show(
(cfg.i18n && cfg.i18n.shareFail) || 'Copy failed',
'error'
);
}
}
);
}
});
}
var hdr = document.getElementById('headerSupportBtn');
if (hdr) {
hdr.addEventListener('click', function (e) {
e.preventDefault();
openSupportModal();
});
headerPulse(hdr);
}
document.querySelectorAll('.js-open-support-modal').forEach(function (btn) {
btn.addEventListener('click', function (e) {
e.preventDefault();
openSupportModal();
});
});
window.addEventListener('online', function () {
applyOfflineState(cfg);
});
window.addEventListener('offline', function () {
applyOfflineState(cfg);
});
}
document.addEventListener('DOMContentLoaded', function () {
var cfg = parseSupportConfig();
if (!cfg) return;
cfg.i18n = cfg.i18n || {};
cfg.i18n.supportAction = cfg.i18n.supportAction || 'Support';
wireTierLinks(cfg);
wireModalDom(cfg);
layoutPromptFromConfig(cfg);
dashboardPrompt();
maybeLongSessionPrompt(cfg);
});
})();
+9
View File
@@ -250,6 +250,15 @@ class ToastNotificationManager {
opacity: '0.95',
textDecoration: 'underline'
});
if (config.actionLink === '__support_modal__') {
actionLink.href = '#';
actionLink.addEventListener('click', function (e) {
e.preventDefault();
if (typeof window.openSupportModal === 'function') {
window.openSupportModal();
}
});
}
content.appendChild(actionLink);
}
+1 -1
View File
@@ -184,7 +184,7 @@
{% if settings.donate_ui_hidden %}
<div class="p-4 rounded-lg bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800">
<p class="text-sm font-medium text-green-800 dark:text-green-200">
<i class="fas fa-check-circle mr-2"></i>{{ _('Donate and support UI are hidden for all users.') }}
<i class="fas fa-check-circle mr-2"></i>{{ _('Supporter instance: prompts are minimized; support entry points remain available.') }}
</p>
</div>
{% else %}
+32 -13
View File
@@ -1054,13 +1054,12 @@
<span class="ml-3 sidebar-label">{{ _('Help') }}</span>
</a>
</li>
{% if current_user.is_authenticated and (not settings or not getattr(settings, 'donate_ui_hidden', false)) and current_user.ui_show_donate %}
{% if current_user.is_authenticated and current_user.ui_show_donate %}
<li class="mt-2">
<a href="{{ url_for('main.donate') }}" class="sidebar-nav-item flex items-center p-2 rounded-lg {% if ep == 'main.donate' %}bg-background-light dark:bg-background-dark text-primary font-semibold{% else %}bg-gradient-to-r from-amber-500/10 to-orange-500/10 border border-amber-500/20 text-amber-600 dark:text-amber-400 hover:from-amber-500/20 hover:to-orange-500/20 hover:border-amber-500/30{% endif %} transition-all duration-200 group" title="{{ _('Support updates — or remove prompts with a key') }}">
<i class="fas fa-mug-saucer w-6 text-center group-hover:scale-110 transition-transform"></i>
<span class="ml-3 sidebar-label font-medium">{{ _('Support Development') }}</span>
<span class="ml-auto text-xs opacity-70"></span>
</a>
<button type="button" class="sidebar-nav-item w-full text-left flex items-center p-2 rounded-lg bg-gradient-to-r from-amber-500/10 to-orange-500/10 border border-amber-500/20 text-amber-600 dark:text-amber-400 hover:from-amber-500/20 hover:to-orange-500/20 hover:border-amber-500/30 transition-all duration-200 group js-open-support-modal" title="{{ _('Open support options') }}">
<i class="fas fa-heart w-6 text-center group-hover:scale-110 transition-transform" aria-hidden="true"></i>
<span class="ml-3 sidebar-label font-medium">{{ _('Support TimeTracker') }}</span>
</button>
</li>
{% endif %}
</ul>
@@ -1126,6 +1125,12 @@
<a href="{{ url_for('main.help') }}" class="flex items-center justify-center w-9 h-9 rounded-lg text-text-light dark:text-text-dark hover:bg-gray-100 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-primary/50" aria-label="{{ _('Help') }}" title="{{ _('Help') }}">
<i class="fas fa-life-ring"></i>
</a>
{% if current_user.is_authenticated %}
<button type="button" id="headerSupportBtn" class="hidden sm:inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-sm font-medium text-amber-800 dark:text-amber-200 bg-amber-500/15 hover:bg-amber-500/25 border border-amber-500/30 dark:border-amber-500/40 focus:outline-none focus:ring-2 focus:ring-amber-500/40" title="{{ _('Support TimeTracker') }}">
<i class="fas fa-heart text-amber-600 dark:text-amber-300" aria-hidden="true"></i>
<span class="max-w-[10rem] truncate">{{ _('Support TimeTracker') }}</span>
</button>
{% endif %}
</div>
<!-- Language Switcher -->
@@ -1164,6 +1169,9 @@
</div>
{% endif %}
<span class="hidden md:inline text-text-light dark:text-text-dark font-medium">{{ current_user.display_name }}</span>
{% if is_license_activated %}
<span class="hidden md:inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-emerald-100 text-emerald-800 dark:bg-emerald-900/40 dark:text-emerald-200 border border-emerald-200/80 dark:border-emerald-700/60" title="{{ _('Supporter') }}">{{ _('Supporter') }}</span>
{% endif %}
{% else %}
<div class="w-8 h-8 rounded-full bg-gray-400 flex items-center justify-center">
<i class="fas fa-user text-white"></i>
@@ -1182,8 +1190,8 @@
{% if current_user.is_authenticated %}
<li><a href="{{ url_for('user.license') }}" class="flex items-center gap-2 px-4 py-2 text-sm text-text-light dark:text-text-dark hover:bg-gray-100 dark:hover:bg-gray-700"><i class="fas fa-key w-4"></i> {{ _('License') }}</a></li>
{% endif %}
{% if not is_license_activated and current_user.is_authenticated %}
<li><a href="{{ support_purchase_url }}" target="_blank" rel="noopener noreferrer" class="flex flex-col gap-0.5 px-4 py-2 text-sm text-amber-600 dark:text-amber-400 hover:bg-gray-100 dark:hover:bg-gray-700"><span class="font-medium"><i class="fas fa-heart w-4" aria-hidden="true"></i> {{ _('Support TimeTracker') }}</span><span class="text-xs text-text-muted-light dark:text-text-muted-dark">{{ _('Buy a license key (€25)') }}</span></a></li>
{% if current_user.is_authenticated %}
<li><button type="button" class="js-open-support-modal w-full text-left flex flex-col gap-0.5 px-4 py-2 text-sm text-amber-600 dark:text-amber-400 hover:bg-gray-100 dark:hover:bg-gray-700"><span class="font-medium"><i class="fas fa-heart w-4" aria-hidden="true"></i> {{ _('Support TimeTracker') }}</span><span class="text-xs text-text-muted-light dark:text-text-muted-dark">{{ _('Donate or get a supporter license') }}</span></button></li>
{% endif %}
<li class="border-t border-border-light dark:border-border-dark"><a href="{{ url_for('auth.logout') }}" class="flex items-center gap-2 px-4 py-2 text-sm text-rose-600 dark:text-rose-400 hover:bg-gray-100 dark:hover:bg-gray-700"><i class="fas fa-sign-out-alt w-4"></i> {{ _('Logout') }}</a></li>
</ul>
@@ -1211,7 +1219,7 @@
{% endwith %}
</div>
{% if current_user.is_authenticated and (not settings or not getattr(settings, 'donate_ui_hidden', false)) and current_user.ui_show_donate %}
{% if current_user.is_authenticated and current_user.ui_show_donate %}
<!-- Dismissible Support Banner -->
<div id="supportBanner" class="bg-gradient-to-r from-amber-50 to-orange-50 dark:from-amber-900/20 dark:to-orange-900/20 border-b border-amber-200 dark:border-amber-800 px-4 py-3 opacity-0 invisible max-h-0 overflow-hidden transition-all duration-300 ease-in-out">
<div class="max-w-7xl mx-auto flex items-center justify-between gap-4">
@@ -1222,7 +1230,7 @@
{{ _('Enjoying TimeTracker?') }}
</p>
<p class="text-xs text-amber-700 dark:text-amber-300" id="bannerMessage">
{{ _('Support updates and new features — or remove prompts with a key') }}
{{ _('Support independent development — licenses are supporter badges, not paywalls.') }}
</p>
</div>
</div>
@@ -1245,9 +1253,9 @@
class="px-3 py-1.5 text-white text-sm font-medium rounded-lg transition-colors border border-blue-600 hover:opacity-90" style="background: linear-gradient(to right, #0070ba, #003087);">
<i class="fab fa-paypal mr-1"></i>{{ _('PayPal') }}
</a>
<a href="{{ support_purchase_url }}" target="_blank" rel="noopener noreferrer" onclick="trackDonationClick('banner_key')" class="text-xs text-amber-700 dark:text-amber-300 hover:underline self-center">
{{ _('Remove prompts with key') }}
</a>
<button type="button" class="text-xs text-amber-700 dark:text-amber-300 hover:underline self-center js-open-support-modal">
{{ _('Support / License') }}
</button>
<button onclick="dismissSupportBanner()"
class="p-1.5 text-amber-600 dark:text-amber-400 hover:bg-amber-100 dark:hover:bg-amber-900/30 rounded transition-colors"
aria-label="{{ _('Dismiss') }}">
@@ -1262,6 +1270,9 @@
<main id="mainContentAnchor" class="flex-1 min-w-0 p-4 sm:p-6 w-full max-w-7xl mx-auto overflow-x-hidden">
{% block content %}{% endblock %}
</main>
{% if current_user.is_authenticated %}
<p class="text-center text-xs text-text-muted-light dark:text-text-muted-dark px-4 pb-2 max-w-7xl mx-auto w-full">{{ _('Built by an independent developer') }}</p>
{% endif %}
</div>
{% if current_user.is_authenticated %}
@@ -1318,6 +1329,11 @@
</div>
{% endif %}
{% if current_user.is_authenticated and support_ui_json %}
<script type="application/json" id="support-ui-bootstrap">{{ support_ui_json|safe }}</script>
{% include 'components/support_modal.html' %}
{% endif %}
</div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.2/js/all.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/animejs/3.2.1/anime.min.js"></script>
@@ -1327,6 +1343,9 @@
<script src="{{ url_for('static', filename='enhanced-search.js') }}"></script>
<script src="{{ url_for('static', filename='form-validation.js') }}"></script>
<script src="{{ url_for('static', filename='toast-notifications.js') }}?v={{ app_version }}-toastfix1"></script>
{% if current_user.is_authenticated and support_ui_json %}
<script src="{{ url_for('static', filename='support-ui.js') }}?v={{ app_version }}-sup1"></script>
{% endif %}
<script src="{{ url_for('static', filename='enhanced-tables.js') }}"></script>
<script src="{{ url_for('static', filename='interactions.js') }}"></script>
<script src="{{ url_for('static', filename='offline-sync.js') }}"></script>
@@ -0,0 +1,56 @@
{# Support & donation modal — strings use Flask-Babel; stats from support_usage_stats_modal #}
{% set s = support_usage_stats_modal or {} %}
<div id="supportModal" class="fixed inset-0 z-[100] hidden" aria-hidden="true" role="dialog" aria-labelledby="supportModalTitle" aria-modal="true">
<div class="absolute inset-0 bg-black/50" data-support-modal-overlay tabindex="-1"></div>
<div class="relative max-w-lg mx-auto mt-16 sm:mt-24 mb-8 px-4 max-h-[calc(100vh-4rem)] overflow-y-auto">
<div class="bg-card-light dark:bg-card-dark text-text-light dark:text-text-dark rounded-xl shadow-xl border border-border-light dark:border-border-dark">
<div class="flex items-start justify-between gap-3 p-5 border-b border-border-light dark:border-border-dark">
<div>
<h2 id="supportModalTitle" class="text-lg font-semibold">{{ _('Support TimeTracker') }}</h2>
<p class="text-sm text-text-muted-light dark:text-text-muted-dark mt-1">
{% if is_license_activated %}
{{ _('Thank you for being a supporter. Sharing the app helps others discover it too.') }}
{% else %}
{{ _('TimeTracker is free and built independently. If it helps you, consider supporting its development.') }}
{% endif %}
</p>
</div>
<button type="button" class="p-2 rounded-lg hover:bg-background-light dark:hover:bg-background-dark text-text-muted-light" data-support-modal-close aria-label="{{ _('Close') }}">
<i class="fas fa-times" aria-hidden="true"></i>
</button>
</div>
<div class="p-5 space-y-4">
<div class="grid grid-cols-3 gap-2 text-center text-sm">
<div class="rounded-lg bg-background-light dark:bg-background-dark p-3">
<div class="text-xs text-text-muted-light dark:text-text-muted-dark">{{ _('Hours tracked') }}</div>
<div class="font-semibold tabular-nums" id="supportStatHours">{{ '%.1f'|format(s.get('total_hours', 0)|float) }}</div>
</div>
<div class="rounded-lg bg-background-light dark:bg-background-dark p-3">
<div class="text-xs text-text-muted-light dark:text-text-muted-dark">{{ _('Entries') }}</div>
<div class="font-semibold tabular-nums" id="supportStatEntries">{{ s.get('time_entries_count', 0)|int }}</div>
</div>
<div class="rounded-lg bg-background-light dark:bg-background-dark p-3">
<div class="text-xs text-text-muted-light dark:text-text-muted-dark">{{ _('Reports') }}</div>
<div class="font-semibold tabular-nums" id="supportStatReports">{{ s.get('reports_generated_count', 0)|int }}</div>
</div>
</div>
<p id="supportSocialLine" class="text-xs text-text-muted-light dark:text-text-muted-dark text-center">{{ _('Trusted by teams and freelancers who want simple, reliable time tracking.') }}</p>
<p id="supportModalOffline" class="hidden text-sm text-amber-700 dark:text-amber-300"></p>
<div class="flex flex-col sm:flex-row gap-2">
<a href="#" rel="noopener noreferrer" data-support-tier="eur5" class="support-tier-btn btn btn-secondary text-center flex-1">{{ _('Donate') }} (€5)</a>
<a href="#" rel="noopener noreferrer" data-support-tier="eur10" class="support-tier-btn btn btn-secondary text-center flex-1">{{ _('Donate') }} (€10)</a>
<a href="#" rel="noopener noreferrer" data-support-tier="eur25" class="support-tier-btn btn btn-secondary text-center flex-1">{{ _('Donate') }} (€25)</a>
</div>
<div class="flex flex-col sm:flex-row gap-2">
<a href="{{ support_purchase_url }}" target="_blank" rel="noopener noreferrer" data-support-tier="license" class="btn btn-primary text-center flex-1">
{{ _('Buy license (€25)') }} <i class="fas fa-external-link-alt text-xs ml-1" aria-hidden="true"></i>
</a>
<button type="button" id="supportShareBtn" class="btn btn-secondary text-center flex-1">{{ _('Love TimeTracker? Share it') }}</button>
</div>
<p class="text-xs text-text-muted-light dark:text-text-muted-dark">
{{ _('A license is a supporter badge — it does not lock features. You keep full access either way.') }}
</p>
</div>
</div>
</div>
</div>
+3 -6
View File
@@ -40,13 +40,10 @@
</div>
</div>
{% if not is_license_activated and current_user.is_authenticated %}
<!-- Support block: same messaging as Settings (optional license key) -->
{% if current_user.is_authenticated and current_user.ui_show_donate %}
<div class="mb-6 sm:mb-8 p-4 sm:p-5 rounded-xl border border-border-light dark:border-border-dark bg-card-light dark:bg-card-dark flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<p class="text-sm text-text-light dark:text-text-dark">{{ _('TimeTracker is free and open-source. If you enjoy using it, you can support development by purchasing a license key.') }}</p>
<a href="{{ support_purchase_url }}" target="_blank" rel="noopener noreferrer" class="btn btn-primary flex-shrink-0 w-full sm:w-auto text-center">
{{ _('Buy license (€25)') }} <i class="fas fa-external-link-alt ml-1 text-xs" aria-hidden="true"></i>
</a>
<p class="text-sm text-text-light dark:text-text-dark">{{ _('TimeTracker is free and open source. You can donate or buy a supporter license — features are never locked.') }}</p>
<button type="button" class="btn btn-primary flex-shrink-0 w-full sm:w-auto text-center js-open-support-modal">{{ _('Support TimeTracker') }}</button>
</div>
{% endif %}
+25 -9
View File
@@ -534,23 +534,39 @@
</div>
</div>
{% if show_support_reminder %}
<!-- Support reminder - once per session, unlicensed only -->
<div class="bg-card-light dark:bg-card-dark border border-amber-200 dark:border-amber-800 p-5 rounded-xl shadow-sm dashboard-widget">
{% if current_user.ui_show_donate %}
<div class="bg-card-light dark:bg-card-dark border border-border-light dark:border-border-dark p-5 rounded-xl shadow-sm dashboard-widget">
<div class="flex items-center gap-2 mb-3">
<div class="bg-amber-500/10 dark:bg-amber-400/10 p-2 rounded-lg">
<i class="fas fa-heart text-amber-600 dark:text-amber-400"></i>
<i class="fas fa-heart text-amber-600 dark:text-amber-400" aria-hidden="true"></i>
</div>
<h2 class="text-base font-semibold text-text-light dark:text-text-dark">{{ _('Enjoying TimeTracker?') }}</h2>
</div>
<p class="text-sm text-text-muted-light dark:text-text-muted-dark mb-4">
{{ _('You can support development by purchasing a license key.') }}
<p class="text-sm text-text-muted-light dark:text-text-muted-dark mb-2">
{{ _('You have tracked %(hours)s hours', hours=('%.1f'|format((usage_support_stats.total_hours or 0)|float))) }}
· {{ _('You have created %(count)s entries', count=usage_support_stats.time_entries_count or 0) }}
{% if (usage_support_stats.reports_generated_count or 0) > 0 %}
· {{ _('Reports generated: %(n)s', n=usage_support_stats.reports_generated_count) }}
{% endif %}
</p>
<a href="{{ support_purchase_url }}" target="_blank" rel="noopener noreferrer" class="inline-flex items-center justify-center w-full sm:w-auto bg-amber-500 hover:bg-amber-600 text-white px-4 py-2 rounded-lg font-medium text-sm transition-colors">
<i class="fas fa-heart mr-1.5" aria-hidden="true"></i>{{ _('Support the project') }}
</a>
{% if is_supporter_instance %}
<p class="text-sm text-text-muted-light dark:text-text-muted-dark mb-4">{{ _('Thank you for supporting development. Sharing TimeTracker still helps a lot.') }}</p>
<div class="flex flex-wrap gap-2">
<button type="button" class="btn btn-secondary js-open-support-modal">{{ _('Share & support') }}</button>
<a href="{{ url_for('user.license') }}" class="btn btn-secondary">{{ _('License') }}</a>
</div>
{% else %}
<p class="text-sm text-text-muted-light dark:text-text-muted-dark mb-4">{{ _('If this saves you time, consider supporting development — everything stays free and open.') }}</p>
<div class="flex flex-wrap gap-2">
<button type="button" class="btn btn-primary js-open-support-modal"><i class="fas fa-heart mr-1.5" aria-hidden="true"></i>{{ _('Donate') }}</button>
<a href="{{ support_purchase_url }}" target="_blank" rel="noopener noreferrer" class="btn btn-secondary">{{ _('Buy License (€25)') }} <i class="fas fa-external-link-alt text-xs" aria-hidden="true"></i></a>
</div>
{% endif %}
</div>
{% endif %}
{% if support_dashboard_prompt %}
<script>window.__TT_DASHBOARD_SUPPORT_PROMPT = {{ support_dashboard_prompt|tojson }};</script>
{% endif %}
</div>
<!-- Delete Entry Confirmation Dialogs -->
{% for entry in recent_entries %}
+7 -7
View File
@@ -831,17 +831,17 @@
<a href="https://github.com/drytrix/TimeTracker/issues" target="_blank" rel="noopener" class="px-4 py-2 rounded-lg border border-amber-600 text-amber-600 hover:bg-amber-50 dark:hover:bg-amber-900/20">
<i class="fas fa-bug mr-1"></i>{{ _('Report Issue') }}
</a>
{% if current_user.is_authenticated and (not settings or not getattr(settings, 'donate_ui_hidden', false)) and current_user.ui_show_donate %}
<a href="{{ url_for('main.donate') }}" class="px-4 py-2 rounded-lg bg-gradient-to-r from-amber-500 to-orange-500 hover:from-amber-600 hover:to-orange-600 text-white font-semibold shadow-md hover:shadow-lg transition-all">
<i class="fas fa-heart mr-1"></i>{{ _('Support updates') }}
</a>
{% if current_user.is_authenticated and current_user.ui_show_donate %}
<button type="button" class="px-4 py-2 rounded-lg bg-gradient-to-r from-amber-500 to-orange-500 hover:from-amber-600 hover:to-orange-600 text-white font-semibold shadow-md hover:shadow-lg transition-all js-open-support-modal">
<i class="fas fa-heart mr-1" aria-hidden="true"></i>{{ _('Support TimeTracker') }}
</button>
{% endif %}
</div>
</div>
{% if not is_license_activated and current_user.is_authenticated %}
{% if current_user.is_authenticated and current_user.ui_show_donate %}
<p class="text-sm text-text-muted-light dark:text-text-muted-dark mt-6 pt-4 border-t border-border-light dark:border-border-dark">
{{ _('Enjoying TimeTracker?') }} {{ _('You can support development by purchasing a license key.') }}
<a href="{{ support_purchase_url }}" target="_blank" rel="noopener noreferrer" class="text-primary hover:underline font-medium">{{ _('Support the project') }}</a>
{{ _('Enjoying TimeTracker?') }} {{ _('Donations and licenses fund development; nothing is paywalled.') }}
<button type="button" class="text-primary hover:underline font-medium js-open-support-modal">{{ _('Open support') }}</button>
</p>
{% endif %}
</section>
+3 -3
View File
@@ -15,10 +15,10 @@
actions_html=None
) }}
{% if not is_license_activated and current_user.is_authenticated %}
{% if current_user.is_authenticated and current_user.ui_show_donate %}
<p class="text-sm text-text-muted-light dark:text-text-muted-dark mb-4">
{{ _('Enjoying TimeTracker?') }} {{ _('You can support development by purchasing a license key.') }}
<a href="{{ support_purchase_url }}" target="_blank" rel="noopener noreferrer" class="text-primary hover:underline font-medium">{{ _('Support the project') }}</a>
{{ _('Enjoying TimeTracker?') }} {{ _('Support development or get a supporter license — the app stays free for everyone.') }}
<button type="button" class="text-primary hover:underline font-medium js-open-support-modal">{{ _('Open support') }}</button>
</p>
{% endif %}
+4 -4
View File
@@ -5,7 +5,7 @@
<div class="container mx-auto px-4 py-8 max-w-2xl">
<div class="mb-6">
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">{{ _('License') }}</h1>
<p class="text-gray-600 dark:text-gray-400 mt-2">{{ _('View activation status and enter a license key') }}</p>
<p class="text-gray-600 dark:text-gray-400 mt-2">{{ _('Supporter badge: confirm your key and thank you for funding development.') }}</p>
</div>
<div class="bg-card-light dark:bg-card-dark border border-border-light dark:border-border-dark rounded-lg shadow-md p-6 mb-6">
@@ -13,16 +13,16 @@
{% if is_license_activated %}
<p class="flex items-center gap-2 text-green-600 dark:text-green-400">
<i class="fas fa-check-circle" aria-hidden="true"></i>
{{ _('Active license') }}
{{ _('Supporter license active') }}
</p>
<p class="text-sm text-text-muted-light dark:text-text-muted-dark mt-2">{{ _('Thank you for supporting TimeTracker.') }}</p>
<p class="text-sm text-text-muted-light dark:text-text-muted-dark mt-2">{{ _('Thank you for supporting TimeTracker. Your badge confirms this instance as a supporter — no features are locked.') }}</p>
{% else %}
<p class="flex items-center gap-2 text-text-muted-light dark:text-text-muted-dark">
<i class="fas fa-circle-info" aria-hidden="true"></i>
{{ _('Not activated') }}
</p>
<p class="text-sm text-text-muted-light dark:text-text-muted-dark mt-2">
{{ _('Want to support development? You can purchase a license key for €25.') }}
{{ _('Purchase a supporter license (€25) to unlock the supporter badge for this instance. The app stays fully free either way.') }}
</p>
<a href="{{ support_purchase_url }}" target="_blank" rel="noopener noreferrer" class="inline-flex items-center gap-2 mt-3 px-4 py-2 bg-primary hover:bg-primary/90 text-white rounded-md text-sm font-medium transition">
{{ _('Buy license (€25)') }} <i class="fas fa-external-link-alt text-xs" aria-hidden="true"></i>
+21 -14
View File
@@ -8,20 +8,27 @@
<p class="text-gray-600 dark:text-gray-400 mt-2">{{ _('Manage your account settings and preferences') }}</p>
</div>
{% if not is_license_activated %}
<div class="mb-6 p-4 rounded-lg border border-border-light dark:border-border-dark bg-card-light dark:bg-card-dark flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<p class="text-sm text-text-light dark:text-text-dark">{{ _('TimeTracker is free and open-source. If you enjoy using it, you can support development by purchasing a license key.') }}</p>
<a href="{{ support_purchase_url }}" target="_blank" rel="noopener noreferrer" class="btn btn-primary flex-shrink-0 w-full sm:w-auto text-center">
{{ _('Buy license (€25)') }} <i class="fas fa-external-link-alt ml-1 text-xs" aria-hidden="true"></i>
</a>
</div>
{% endif %}
<div class="mb-6">
<a href="{{ url_for('user.license') }}" class="inline-flex items-center gap-2 px-4 py-2 rounded-lg border border-border-light dark:border-border-dark bg-card-light dark:bg-card-dark text-text-light dark:text-text-dark hover:bg-gray-50 dark:hover:bg-gray-700/50 transition text-sm font-medium">
<i class="fas fa-key" aria-hidden="true"></i>{{ _('License') }}
</a>
</div>
<details class="bg-card-light dark:bg-card-dark rounded-lg shadow-md group mb-6" open>
<summary class="flex items-center justify-between p-6 cursor-pointer select-none list-none [&::-webkit-details-marker]:hidden">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">
<i class="fas fa-heart mr-2 text-amber-600 dark:text-amber-400" aria-hidden="true"></i>{{ _('Support & Community') }}
</h2>
<i class="fas fa-chevron-down text-gray-400 transition-transform group-open:rotate-180 md:hidden"></i>
</summary>
<div class="px-6 pb-6 space-y-4 text-sm text-text-light dark:text-text-dark">
<p class="text-text-muted-light dark:text-text-muted-dark">{{ _('TimeTracker is free and open source. Funding comes from optional donations and supporter licenses — never from locking features.') }}</p>
{% if is_license_activated %}
<p>{{ _('This instance already has a supporter license. Thank you — you can still donate or share the app anytime.') }}</p>
{% else %}
<p>{{ _('If the app saves you time, you can donate or buy a supporter license (€25). A license shows a Supporter badge; it does not change what you can use.') }}</p>
{% endif %}
<div class="flex flex-wrap gap-2">
<button type="button" class="btn btn-primary js-open-support-modal">{{ _('Support TimeTracker') }}</button>
<a href="{{ url_for('user.license') }}" class="btn btn-secondary inline-flex items-center gap-2"><i class="fas fa-key" aria-hidden="true"></i>{{ _('License & supporter key') }}</a>
<a href="{{ support_purchase_url }}" target="_blank" rel="noopener noreferrer" class="btn btn-secondary">{{ _('Checkout on timetracker.drytrix.com') }} <i class="fas fa-external-link-alt text-xs" aria-hidden="true"></i></a>
</div>
</div>
</details>
<form method="POST" class="space-y-8">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
+95 -2
View File
@@ -1,5 +1,8 @@
from flask import current_app, g, request
from flask_babel import get_locale
import json
from datetime import datetime
from flask import current_app, g, request, session, url_for
from flask_babel import get_locale, gettext as _
from flask_login import current_user
from app.models import Settings
@@ -178,6 +181,93 @@ def register_context_processors(app):
getattr(current_user, "is_authenticated", False) and getattr(current_user, "is_admin", False)
)
support_ui_json = None
layout_support_prompt = None
support_usage_stats_modal = None
if getattr(current_user, "is_authenticated", False):
try:
from app.config.support_ui import (
build_support_checkout_urls,
get_long_session_minutes,
get_social_proof_text,
)
from app.models import Settings
from app.services.support_prompt_service import SupportPromptService
from app.services.usage_stats_service import UsageStatsService
from app.utils.license_utils import is_license_activated
settings_obj = Settings.get_settings()
is_supporter_instance = bool(settings_obj and is_license_activated(settings_obj))
ui_show_donate = bool(getattr(current_user, "ui_show_donate", True))
layout_support_prompt = SupportPromptService.consume_layout_prompt(
session,
ui_show_donate=ui_show_donate,
is_supporter=is_supporter_instance,
support_banner_suppressed=support_banner_suppressed,
)
usage_stats = UsageStatsService.get_for_user(current_user.id)
support_usage_stats_modal = usage_stats
checkout_urls = build_support_checkout_urls(current_app.config)
social_line = get_social_proof_text(current_app.config)
long_session_minutes = get_long_session_minutes()
if not session.get("support_session_started_at"):
session["support_session_started_at"] = (
datetime.utcnow().replace(microsecond=0).isoformat() + "Z"
)
lp_message = ""
lp_action = _("Support")
if layout_support_prompt:
v = layout_support_prompt.get("variant")
if v == SupportPromptService.VARIANT_AFTER_REPORT:
lp_message = _(
"That report was quick to generate. If TimeTracker saves you time, "
"consider supporting its development."
)
support_ui_json = json.dumps(
{
"urls": checkout_urls,
"stats": usage_stats,
"socialProofLine": social_line,
"longSessionMinutes": long_session_minutes,
"isSupporter": is_supporter_instance,
"sessionStartedAt": session.get("support_session_started_at"),
"shareUrl": url_for("main.about", _external=True),
"trackUrl": url_for("main.track_support_event"),
"softPromptUrl": url_for("main.request_soft_support_prompt"),
"layoutPrompt": (
{
"variant": layout_support_prompt.get("variant"),
"message": lp_message,
"actionLabel": lp_action,
}
if layout_support_prompt
else None
),
"i18n": {
"offlineNote": _(
"You appear to be offline. Reconnect to open donation or checkout links."
),
"shareSuccess": _("Link copied to clipboard"),
"shareFail": _("Could not copy link"),
"supportAction": _("Support"),
"longSessionToast": _(
"You have been using TimeTracker actively for a while. "
"If it helps your work, consider supporting its development."
),
},
},
ensure_ascii=False,
)
except Exception:
support_ui_json = None
layout_support_prompt = None
support_usage_stats_modal = None
return {
"app_name": "Time Tracker",
"app_version": version_value,
@@ -195,6 +285,9 @@ def register_context_processors(app):
"user_stats": user_stats,
"support_banner_suppressed": support_banner_suppressed,
"support_ab_variant": support_ab_variant,
"support_ui_json": support_ui_json,
"layout_support_prompt": layout_support_prompt,
"support_usage_stats_modal": support_usage_stats_modal,
}
@app.context_processor
+4 -4
View File
@@ -1,12 +1,12 @@
"""
Optional support/license visibility helpers.
Instance-level "license activated" state is represented by Settings.donate_ui_hidden
(set when a user verifies the donate-hide / license key). This is non-blocking
monetization awareness onlyno paywall or feature gating.
Instance-level supporter state is represented by Settings.donate_ui_hidden
(set when a user verifies a license / supporter key). Non-blocking: no paywall
or feature gating; UI treats this as a supporter badge and softer prompts.
"""
def is_license_activated(settings) -> bool:
"""Return True if this instance has an active license (donate/support UI hidden)."""
"""Return True if this instance has an activated supporter / license key."""
return bool(getattr(settings, "donate_ui_hidden", False))
+16
View File
@@ -0,0 +1,16 @@
"""Hook successful report exports/views for support stats and soft prompts."""
from __future__ import annotations
def record_report_generation_for_current_user() -> None:
"""Increment per-user report counter and queue a one-shot support prompt trigger."""
from flask import session
from flask_login import current_user
from app.services.usage_stats_service import UsageStatsService
if not getattr(current_user, "is_authenticated", False):
return
UsageStatsService.increment_reports_generated(current_user.id)
session["support_prompt_trigger"] = "after_report"
+10
View File
@@ -73,6 +73,16 @@ This document lists events tracked via PostHog and JSON logging.
| `export.csv` | User exports data to CSV | `user_id`, `export_type`, `row_count` |
| `export.pdf` | User exports data to PDF | `user_id`, `export_type` |
## Support & donation funnel (opt-in layer)
| Event Name | Description | Properties |
|-----------|-------------|-------------|
| `support.modal_opened` | User opened the support modal | `variant`, `source` |
| `support.donation_clicked` | User chose a donation tier from the modal | `variant` (tier key), `source` |
| `support.license_clicked` | User opened supporter checkout / license from the modal | `source` |
| `support.prompt_shown` | Soft support toast or prompt was shown | `variant`, `source` |
| `support.prompt_dismissed` | User dismissed a soft support prompt | `variant`, `source` |
## Comment Events
| Event Name | Description | Properties |
+15
View File
@@ -20,6 +20,7 @@ TimeTracker provides privacy-aware analytics and monitoring with Grafana Cloud O
- Product events such as `timer.started`, `project.created`, `auth.login`
- Sent only when admins enable detailed analytics in the app
- PII-filtered before export
- Support UI funnel events (`support.modal_opened`, `support.donation_clicked`, etc.) are emitted the same way when opt-in is enabled; see [all_tracked_events.md](all_tracked_events.md).
## Configuration
@@ -34,8 +35,22 @@ ENABLE_TELEMETRY=true
# Optional error monitoring
SENTRY_DSN=
SENTRY_TRACES_RATE=0.1
# Support / checkout links (optional; defaults in app/config.py)
SUPPORT_PURCHASE_URL=https://timetracker.drytrix.com/support.html
SUPPORT_PORTAL_BASE=https://timetracker.drytrix.com
# Optional one line shown in the support modal when set
SUPPORT_SOCIAL_PROOF_TEXT=
# Optional per-tier donate URLs (default to SUPPORT_PURCHASE_URL when unset)
SUPPORT_DONATE_EUR5_URL=
SUPPORT_DONATE_EUR10_URL=
SUPPORT_DONATE_EUR25_URL=
# Long-session soft prompt threshold in minutes (default 120)
SUPPORT_LONG_SESSION_MINUTES=120
```
Per-user **report generation counts** for the support modal are stored in `users.support_stats_reports_generated` (see migration `149_add_user_support_stats_reports_generated`).
## Troubleshooting
- If no telemetry arrives, verify `GRAFANA_OTLP_ENDPOINT` and `GRAFANA_OTLP_TOKEN`
+1 -1
View File
@@ -23,7 +23,7 @@ This document describes the privacy-aware, two-layer telemetry system: **base te
## Detailed Analytics (Opt-In Only)
- **Gated by:** `is_telemetry_enabled()` / `allow_analytics`. No product events sent without opt-in.
- **Events:** Existing names (e.g. `auth.login`, `timer.started`, `project.created`). Optional prefix `analytics.*` in future.
- **Events:** Existing names (e.g. `auth.login`, `timer.started`, `project.created`). Support funnel events use the `support.*` prefix (e.g. `support.modal_opened`); see [all_tracked_events.md](all_tracked_events.md). Optional prefix `analytics.*` in future.
- **Properties:** Include `install_id`, app_version, deployment, request context (path, browser, device) only when opted in.
- **Sink:** Grafana Cloud OTLP (`identity = user_id` for events).
- **Retention:** Per Grafana retention policy. Document in privacy policy.
@@ -0,0 +1,43 @@
"""Add users.support_stats_reports_generated for support modal stats.
Revision ID: 149_add_user_support_stats_reports_generated
Revises: 148_add_user_dismissed_release_version
Create Date: 2026-04-15
"""
import sqlalchemy as sa
from alembic import op
from sqlalchemy import inspect
revision = "149_add_user_support_stats_reports_generated"
down_revision = "148_add_user_dismissed_release_version"
branch_labels = None
depends_on = None
def _has_column(inspector, table_name: str, column_name: str) -> bool:
try:
return column_name in {c["name"] for c in inspector.get_columns(table_name)}
except Exception:
return False
def upgrade():
bind = op.get_bind()
inspector = inspect(bind)
if "users" not in inspector.get_table_names():
return
if not _has_column(inspector, "users", "support_stats_reports_generated"):
op.add_column(
"users",
sa.Column("support_stats_reports_generated", sa.Integer(), nullable=False, server_default="0"),
)
def downgrade():
bind = op.get_bind()
inspector = inspect(bind)
if "users" not in inspector.get_table_names():
return
if _has_column(inspector, "users", "support_stats_reports_generated"):
op.drop_column("users", "support_stats_reports_generated")
+63
View File
@@ -0,0 +1,63 @@
"""Tests for support prompt and usage stats services."""
from app.services.support_prompt_service import SupportPromptService
from app.services.usage_stats_service import UsageStatsService
def test_usage_stats_service_shape(app, test_user):
with app.app_context():
s = UsageStatsService.get_for_user(test_user.id)
assert "total_hours" in s
assert "time_entries_count" in s
assert "days_since_signup" in s
assert "reports_generated_count" in s
def test_consume_layout_prompt_sets_consumed():
session = {"support_prompt_trigger": SupportPromptService.VARIANT_AFTER_REPORT}
payload = SupportPromptService.consume_layout_prompt(
session,
ui_show_donate=True,
is_supporter=False,
support_banner_suppressed=False,
)
assert payload is not None
assert payload.get("variant") == SupportPromptService.VARIANT_AFTER_REPORT
assert session.get(SupportPromptService.SESSION_SOFT_PROMPT_CONSUMED) is True
assert "support_prompt_trigger" not in session
def test_support_prompt_suppressed_for_supporter():
session = {"support_prompt_trigger": SupportPromptService.VARIANT_AFTER_REPORT}
payload = SupportPromptService.consume_layout_prompt(
session,
ui_show_donate=True,
is_supporter=True,
support_banner_suppressed=False,
)
assert payload is None
def test_support_prompt_respects_ui_show_donate():
session = {"support_prompt_trigger": SupportPromptService.VARIANT_AFTER_REPORT}
payload = SupportPromptService.consume_layout_prompt(
session,
ui_show_donate=False,
is_supporter=False,
support_banner_suppressed=False,
)
assert payload is None
def test_pick_dashboard_skips_when_after_report_pending():
session = {"support_prompt_trigger": SupportPromptService.VARIANT_AFTER_REPORT}
user_stats = {"days_since_signup": 100, "time_entries_count": 1, "total_hours": 1.0}
picked = SupportPromptService.pick_dashboard_prompt(
session,
user_stats,
ui_show_donate=True,
is_supporter=False,
support_banner_suppressed=False,
today_hours=8.0,
)
assert picked is None