mirror of
https://github.com/DRYTRIX/TimeTracker.git
synced 2026-05-12 07:19:49 -05:00
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:
@@ -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))
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
)
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
@@ -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():
|
||||
|
||||
@@ -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
@@ -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):
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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);
|
||||
});
|
||||
})();
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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>
|
||||
@@ -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 %}
|
||||
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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() }}"/>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 only—no 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))
|
||||
|
||||
@@ -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"
|
||||
@@ -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 |
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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")
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user