From bd60c58a36cd9b7315d46d8c22aff3d903cc2795 Mon Sep 17 00:00:00 2001
From: Dries Peeters
Date: Fri, 18 Sep 2026 05:54:53 +0200
Subject: [PATCH 01/11] feat(support): add milestone celebration modal and
clearer key CTA copy
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Surface a dedicated celebration dialog for support milestones and tighten donate/support prompts so the €25 license key path is more obvious.
---
app/models/donation_interaction.py | 2 +-
app/routes/main.py | 9 ++-
app/static/support-ui.js | 83 +++++++++++++++++++++
app/templates/base.html | 80 ++++++++++++++------
app/templates/components/support_modal.html | 11 ++-
app/templates/main/dashboard.html | 15 +++-
app/templates/main/donate.html | 15 +++-
app/utils/context_processors.py | 5 +-
docs/admin/SUPPORT_CONVERSION_AB_TESTS.md | 5 +-
9 files changed, 189 insertions(+), 36 deletions(-)
diff --git a/app/models/donation_interaction.py b/app/models/donation_interaction.py
index d1695b95..d037adfe 100644
--- a/app/models/donation_interaction.py
+++ b/app/models/donation_interaction.py
@@ -34,7 +34,7 @@ class DonationInteraction(db.Model):
# Placement/source: header | banner | banner_bmc | banner_paypal | banner_key | dashboard_widget | donate_page_* | about_page | help_page
source = db.Column(db.String(100), nullable=True)
- # A/B test variant for experiments (e.g. control | key_first | cta_alt)
+ # A/B test variant for experiments (e.g. control | key_first | cta_alt | never_see)
variant = db.Column(db.String(50), nullable=True)
# User metrics at time of interaction (for smart prompts)
diff --git a/app/routes/main.py b/app/routes/main.py
index e866e7e7..06b79189 100644
--- a/app/routes/main.py
+++ b/app/routes/main.py
@@ -467,10 +467,15 @@ def dashboard():
support_dashboard_prompt = {
**support_dashboard_prompt,
"message": _(
- "You've tracked %(hours)s hours with TimeTracker. That's reliable data for your clients "
- "and your business — consider supporting continued development.",
+ "TimeTracker has kept you on track for %(hours)s hours. "
+ "Consider buying a key to remove prompts and support future development.",
hours=milestone,
),
+ "celebration": True,
+ "title": _("%(hours)s hours tracked!", hours=milestone),
+ "keyLabel": _("Get key (€25)"),
+ "bmcLabel": _("Buy a coffee"),
+ "dismissLabel": _("Maybe later"),
}
elif v == SupportPromptService.VARIANT_ACTIVE_TODAY:
support_dashboard_prompt = {
diff --git a/app/static/support-ui.js b/app/static/support-ui.js
index e20021ff..cea7e4e8 100644
--- a/app/static/support-ui.js
+++ b/app/static/support-ui.js
@@ -174,10 +174,93 @@
showSoftToast(cfg, cfg.layoutPrompt.message, cfg.layoutPrompt.variant || 'after_report', 'layout');
}
+ function showMilestoneCelebration(cfg, raw) {
+ if (document.getElementById('supportMilestoneCelebration')) return;
+
+ var purchaseUrl =
+ (cfg && cfg.urls && cfg.urls.license) ||
+ 'https://timetracker.drytrix.com/support.html';
+ var bmcUrl =
+ 'https://buymeacoffee.com/DryTrix?utm_source=timetracker&utm_medium=milestone_toast&utm_campaign=support';
+ var title = raw.title || raw.message || 'Milestone reached!';
+ var message =
+ raw.message ||
+ 'Consider buying a key to remove prompts and support future development.';
+ var keyLabel = raw.keyLabel || 'Get key (€25)';
+ var bmcLabel = raw.bmcLabel || 'Buy a coffee';
+ var dismissLabel = raw.dismissLabel || 'Maybe later';
+
+ var overlay = document.createElement('div');
+ overlay.id = 'supportMilestoneCelebration';
+ overlay.className = 'fixed inset-0 z-[110] flex items-center justify-center p-4';
+ overlay.setAttribute('role', 'dialog');
+ overlay.setAttribute('aria-modal', 'true');
+ overlay.setAttribute('aria-labelledby', 'supportMilestoneTitle');
+ overlay.innerHTML =
+ '
' +
+ '' +
+ '
' +
+ '
🎉
' +
+ '
' +
+ '
' +
+ '
' +
+ '
' +
+ '
' +
+ '
' +
+ '
' +
+ '
';
+
+ overlay.querySelector('#supportMilestoneTitle').textContent = title;
+ overlay.querySelector('[data-milestone-msg]').textContent = message;
+ var keyBtn = overlay.querySelector('[data-milestone-key]');
+ keyBtn.textContent = keyLabel;
+ var bmcBtn = overlay.querySelector('[data-milestone-bmc]');
+ bmcBtn.innerHTML = ' ' + bmcLabel;
+ overlay.querySelectorAll('[data-milestone-dismiss]').forEach(function (el) {
+ if (el.tagName === 'BUTTON') el.textContent = dismissLabel;
+ el.addEventListener('click', function () {
+ if (overlay.parentNode) overlay.parentNode.removeChild(overlay);
+ });
+ });
+ keyBtn.addEventListener('click', function () {
+ postTrack(cfg, 'license_clicked', { source: 'milestone_toast', variant: 'hours_milestone' });
+ if (typeof window.trackDonationClick === 'function') {
+ window.trackDonationClick('milestone_key');
+ }
+ });
+ bmcBtn.addEventListener('click', function () {
+ postTrack(cfg, 'donation_clicked', { source: 'milestone_toast', variant: 'bmc' });
+ if (typeof window.trackDonationClick === 'function') {
+ window.trackDonationClick('milestone_bmc');
+ }
+ });
+ document.addEventListener('keydown', function onEsc(ev) {
+ if (ev.key === 'Escape' && overlay.parentNode) {
+ overlay.parentNode.removeChild(overlay);
+ document.removeEventListener('keydown', onEsc);
+ }
+ });
+
+ document.body.appendChild(overlay);
+ postTrack(cfg, 'prompt_shown', {
+ variant: 'hours_milestone',
+ source: 'milestone_celebration',
+ milestone: raw.milestone
+ });
+ }
+
function dashboardPrompt() {
var cfg = parseSupportConfig();
var raw = window.__TT_DASHBOARD_SUPPORT_PROMPT;
if (!cfg || !raw || !raw.message) return;
+ if (raw.variant === 'hours_milestone' || raw.celebration) {
+ showMilestoneCelebration(cfg, raw);
+ return;
+ }
showSoftToast(cfg, raw.message, raw.variant || 'dashboard', raw.source || 'dashboard');
}
diff --git a/app/templates/base.html b/app/templates/base.html
index 2d28ad84..01eb5ee9 100644
--- a/app/templates/base.html
+++ b/app/templates/base.html
@@ -72,19 +72,25 @@
{{ _('Become a Supporter or buy a coffee — every contribution helps keep TimeTracker free for everyone.') }}
+
+ {{ _('One-time key — pay once, never see this again.') }}
+
-
- {{ _('Become a Supporter') }}
+ {{ _('Remove prompts forever (€25)') }}
- {{ _('Buy Me a Coffee') }}
+ class="px-3 py-1.5 bg-white hover:bg-amber-50 text-amber-600 text-sm font-semibold rounded-lg transition-colors border-2 border-amber-600 shadow-sm">
+ {{ _('Buy me a coffee') }}
{{ _('AI H
if (banner) {
banner.classList.add('opacity-0', 'invisible', 'max-h-0', 'overflow-hidden');
banner.classList.remove('opacity-100', 'visible', 'max-h-[100px]');
- // Store dismissal timestamp (show again after 30 days)
+ // Store dismissal timestamp (show again after 14 days)
try {
localStorage.setItem('supportBannerDismissed', Date.now().toString());
// Track dismissal
@@ -1154,22 +1160,22 @@ {{ _('AI H
function shouldShowSupportBanner() {
try {
- // Check if dismissed recently (30 days)
+ // Check if dismissed recently (14 days)
const dismissed = localStorage.getItem('supportBannerDismissed');
if (dismissed) {
const dismissedTime = parseInt(dismissed);
- const thirtyDays = 30 * 24 * 60 * 60 * 1000; // 30 days in milliseconds
- if ((Date.now() - dismissedTime) < thirtyDays) {
+ const fourteenDays = 14 * 24 * 60 * 60 * 1000;
+ if ((Date.now() - dismissedTime) < fourteenDays) {
return false;
}
}
- // Check if user clicked donation link recently (30 days)
+ // Check if user clicked donation link recently (14 days)
const lastClick = localStorage.getItem('donationLinkClicked');
if (lastClick) {
const clickTime = parseInt(lastClick);
- const thirtyDays = 30 * 24 * 60 * 60 * 1000;
- if ((Date.now() - clickTime) < thirtyDays) {
+ const fourteenDays = 14 * 24 * 60 * 60 * 1000;
+ if ((Date.now() - clickTime) < fourteenDays) {
return false;
}
}
@@ -1179,34 +1185,60 @@ {{ _('AI H
return true; // Show by default if localStorage fails
}
}
+
+ function getSupportBannerSeenCount() {
+ try {
+ return parseInt(localStorage.getItem('supportBannerSeenCount') || '0', 10) || 0;
+ } catch (e) {
+ return 0;
+ }
+ }
+
+ function incrementSupportBannerSeenCount() {
+ try {
+ const next = getSupportBannerSeenCount() + 1;
+ localStorage.setItem('supportBannerSeenCount', String(next));
+ return next;
+ } catch (e) {
+ return 1;
+ }
+ }
- function updateBannerMessage() {
- // Get user stats from page if available
+ function updateBannerMessage(seenCount) {
const bannerTitle = document.getElementById('bannerTitle');
const bannerMessage = document.getElementById('bannerMessage');
if (!bannerTitle || !bannerMessage) return;
-
- // Try to get user stats from data attributes or API
+
+ const count = typeof seenCount === 'number' ? seenCount : getSupportBannerSeenCount();
const userStats = window.userStats || {};
const daysSinceSignup = userStats.days_since_signup || 0;
const timeEntriesCount = userStats.time_entries_count || 0;
const totalHours = userStats.total_hours || 0;
-
- // Smart messaging based on milestones
+
+ // Escalating copy based on how often the banner has been shown
+ if (count >= 5) {
+ bannerTitle.textContent = '{{ _("Still here?") }}';
+ bannerMessage.textContent = '{{ _("Buy a key — it removes all prompts permanently.") }}';
+ return;
+ }
+ if (count >= 3) {
+ bannerTitle.textContent = {{ _("You've seen this %(n)s times.")|tojson }}.replace('%(n)s', String(count));
+ bannerMessage.textContent = '{{ _("A one-time key removes it forever.") }}';
+ return;
+ }
+
+ // Smart messaging based on milestones (first 1–2 impressions)
if (totalHours >= 100) {
bannerTitle.textContent = '{{ _("Amazing! You\'ve tracked over 100 hours") }}';
- bannerMessage.textContent = '{{ _("Support updates and new features — or remove prompts with a key") }} ☕';
} else if (timeEntriesCount >= 50) {
bannerTitle.textContent = '{{ _("Great progress! You\'ve logged 50+ entries") }}';
- bannerMessage.textContent = '{{ _("Support updates and new features — or remove prompts with a key") }} ☕';
} else if (daysSinceSignup >= 7) {
bannerTitle.textContent = '{{ _("Thanks for using TimeTracker!") }}';
- bannerMessage.textContent = '{{ _("Support updates and new features — or remove prompts with a key") }} ☕';
} else {
bannerTitle.textContent = '{{ _("Enjoying TimeTracker?") }}';
- bannerMessage.textContent = '{{ _("Support updates and new features — or remove prompts with a key") }} ☕';
}
+ bannerMessage.textContent = '{{ _("Support its development — or remove prompts forever with a key.") }}';
}
// Show support banner if conditions are met
@@ -1214,12 +1246,12 @@ {{ _('AI H
(function() {
const banner = document.getElementById('supportBanner');
if (!banner) return;
- // Server-side suppression: don't show if user clicked a support CTA in last 30 days
+ // Server-side suppression: don't show if user clicked a support CTA recently
if ({{ 'true' if support_banner_suppressed else 'false' }}) { return; }
if (shouldShowSupportBanner()) {
- // Update banner message based on user stats
- updateBannerMessage();
+ const seenCount = incrementSupportBannerSeenCount();
+ updateBannerMessage(seenCount);
// Reserve space immediately by removing height constraints
// This prevents layout shift when banner becomes visible
diff --git a/app/templates/components/support_modal.html b/app/templates/components/support_modal.html
index 7ef20beb..fdc02463 100644
--- a/app/templates/components/support_modal.html
+++ b/app/templates/components/support_modal.html
@@ -37,6 +37,15 @@ {{ _('Support TimeTrack
{{ _('Trusted by teams and freelancers who want simple, reliable time tracking.') }}
{% if not is_license_activated %}
+
+ {{ _('Buy Me a Coffee') }}
+
+
{{ _('Or choose an amount:') }}
+
+
+
{{ _('How did we do?') }}
+
+ {% if client %}{{ client.name }}{% endif %}
+
+
+
+ {% with messages = get_flashed_messages(with_categories=true) %}
+ {% if messages %}
+
+ {% for category, message in messages %}
+
{{ message }}
+ {% endfor %}
+
+ {% endif %}
+ {% endwith %}
+
+
+ {% if survey.is_completed %}
+
{{ _('Thanks — your feedback was recorded.') }}
+ {% if survey.nps_score is not none %}
+
{{ _('Your score:') }} {{ survey.nps_score }} /10
+ {% endif %}
+ {% elif survey.is_expired %}
+
{{ _('This feedback link has expired.') }}
+ {% else %}
+
+ {% endif %}
+
+
+
diff --git a/app/templates/client_portal/survey.html b/app/templates/client_portal/survey.html
new file mode 100644
index 00000000..80b3623c
--- /dev/null
+++ b/app/templates/client_portal/survey.html
@@ -0,0 +1,62 @@
+
+
+