diff --git a/app/config.py b/app/config.py index 7e2c227..74bcd4b 100644 --- a/app/config.py +++ b/app/config.py @@ -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)) diff --git a/app/config/support_ui.py b/app/config/support_ui.py new file mode 100644 index 0000000..27ea524 --- /dev/null +++ b/app/config/support_ui.py @@ -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 diff --git a/app/models/donation_interaction.py b/app/models/donation_interaction.py index fa84e09..d1695b9 100644 --- a/app/models/donation_interaction.py +++ b/app/models/donation_interaction.py @@ -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 ) diff --git a/app/models/user.py b/app/models/user.py index 59178b4..69821ee 100644 --- a/app/models/user.py +++ b/app/models/user.py @@ -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") diff --git a/app/routes/custom_reports.py b/app/routes/custom_reports.py index 826c311..7eccd1a 100644 --- a/app/routes/custom_reports.py +++ b/app/routes/custom_reports.py @@ -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) diff --git a/app/routes/main.py b/app/routes/main.py index 55f8905..2552be4 100644 --- a/app/routes/main.py +++ b/app/routes/main.py @@ -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(): diff --git a/app/routes/reports.py b/app/routes/reports.py index 0919ff4..fa32b48 100644 --- a/app/routes/reports.py +++ b/app/routes/reports.py @@ -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", diff --git a/app/routes/user.py b/app/routes/user.py index 10c3424..7398c6a 100644 --- a/app/routes/user.py +++ b/app/routes/user.py @@ -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): diff --git a/app/services/support_prompt_service.py b/app/services/support_prompt_service.py new file mode 100644 index 0000000..817f05e --- /dev/null +++ b/app/services/support_prompt_service.py @@ -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 diff --git a/app/services/usage_stats_service.py b/app/services/usage_stats_service.py new file mode 100644 index 0000000..bb9fa7a --- /dev/null +++ b/app/services/usage_stats_service.py @@ -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 diff --git a/app/static/support-ui.js b/app/static/support-ui.js new file mode 100644 index 0000000..beeda28 --- /dev/null +++ b/app/static/support-ui.js @@ -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); + }); +})(); diff --git a/app/static/toast-notifications.js b/app/static/toast-notifications.js index 1325cd6..7bd9a27 100644 --- a/app/static/toast-notifications.js +++ b/app/static/toast-notifications.js @@ -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); } diff --git a/app/templates/admin/settings.html b/app/templates/admin/settings.html index abd6a5c..8ba878f 100644 --- a/app/templates/admin/settings.html +++ b/app/templates/admin/settings.html @@ -184,7 +184,7 @@ {% if settings.donate_ui_hidden %}
- {{ _('Donate and support UI are hidden for all users.') }} + {{ _('Supporter instance: prompts are minimized; support entry points remain available.') }}
- {{ _('Support updates and new features — or remove prompts with a key') }} ☕ + {{ _('Support independent development — licenses are supporter badges, not paywalls.') }}