From c1daf62f09257ba2324f18dfc3504a922877f79c Mon Sep 17 00:00:00 2001
From: Dries Peeters
- {{ _('Support independent development — licenses are supporter badges, not paywalls.') }} + {{ _('Become a Supporter or buy a coffee — every contribution helps keep TimeTracker free for everyone.') }}
diff --git a/app/templates/main/donate.html b/app/templates/main/donate.html index e942ff96..03ffb73e 100644 --- a/app/templates/main/donate.html +++ b/app/templates/main/donate.html @@ -17,7 +17,7 @@{% if (support_ab_variant|default('control')) == 'cta_alt' %} - {{ _('Donate to support development — or get a key to remove prompts') }} + {{ _('Donate to support development — or become a Supporter to show your badge') }} {% else %} {{ _('Support updates and keep TimeTracker free for everyone') }} {% endif %} @@ -26,7 +26,7 @@
- {{ _('Remove prompts with a one-time key.') }} - {{ _('Get key') }} + {{ _('Supporters get a visible badge and fewer prompts — no features are locked.') }} + {{ _('Become a Supporter') }} +
+ + + + ++ {{ _('I built TimeTracker because I needed simple, reliable time tracking for my own work — without subscriptions, vendor lock-in, or features hidden behind a paywall.') }} +
++ {{ _('Keeping it running means real costs: hosting, databases, domains, and the time to fix bugs, ship improvements, and answer community questions. When you donate or become a Supporter, that money goes directly toward those costs — not a corporation, just one independent developer trying to build something useful.') }} +
++ {{ _('Thank you for using TimeTracker. Whether you contribute financially, star the repo, or spread the word — it genuinely helps.') }}
- {{ _('TimeTracker is a free, open-source project built with passion and dedication. Your donations directly support:') }} + {{ _('Real numbers — every contribution, no matter the size, makes a difference:') }}
- {{ _('Hosting, databases, and CDN costs to keep TimeTracker fast and reliable') }} + {{ _('Covers roughly a month of basic hosting for the project website and documentation') }}
- {{ _('New features, improvements, and bug fixes based on your feedback') }} + {{ _('Helps pay for server resources, CDN, and database costs that keep releases and downloads available') }}
- {{ _('Regular security updates, dependency maintenance, and performance optimization') }} + {{ _('Covers about a quarter of server and domain costs, plus shows a Supporter badge in the app — no features locked') }}
- {{ _('Translation support, localization, and making TimeTracker accessible worldwide') }} + {{ _('Funds bug fixes, new features, translations, and keeping TimeTracker free for everyone') }}
- {{ _('One key per instance; key sent by email after payment (€25 one-time). No subscription.') }} + {{ _('One-time €25 contribution per instance. You receive a key by email. Supporters get a visible badge; support prompts are minimized as a side effect. No subscription.') }}
{{ _('Supporter badge: confirm your key and thank you for funding development.') }}
+{{ _('Support TimeTracker development and show your Supporter badge across the app.') }}
+ + {{ _('Thank you for being a Supporter!') }} +
++ {{ _('Your contribution helps keep TimeTracker free and actively maintained. You will see your Supporter badge in the app, and support prompts are minimized for everyone on this instance.') }} +
+{{ _('Supporter license active') }}
-{{ _('Thank you for supporting TimeTracker. Your badge confirms this instance as a supporter — no features are locked.') }}
+{{ _('No features are locked — this is a thank-you badge, not a paywall.') }}
{% else %}- {{ _('Not activated') }} + {{ _('Not yet a Supporter') }}
- {{ _('Purchase a supporter license (€25) to unlock the supporter badge for this instance. The app stays fully free either way.') }} + {{ _('Become a Supporter with a one-time €25 contribution. You keep full access either way — supporters get a visible badge and fewer prompts.') }}
- {{ _('Buy license (€25)') }} + {{ _('Support TimeTracker (€25)') }} {% endif %}{{ _('Paste the license key you received by email after purchase.') }}
+{{ _('Already purchased? Paste the key you received by email. As a side effect, support prompts are minimized for this instance.') }}
{{ _('Need a key?') }} - {{ _('Purchase a license key') }} + {{ _('Become a Supporter') }}
{{ _('TimeTracker is free and open source. Funding comes from optional donations and supporter licenses — never from locking features.') }}
{% if is_license_activated %} -{{ _('This instance already has a supporter license. Thank you — you can still donate or share the app anytime.') }}
+{{ _('Thank you for being a TimeTracker Supporter. Your badge shows across the app — you can still donate or share anytime.') }}
{% else %} -{{ _('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.') }}
+{{ _('If the app saves you time, become a Supporter with a one-time €25 contribution. You keep full access either way — supporters get a visible badge and fewer prompts.') }}
{% endif %}- {{ _('Support visibility (hiding donate/support UI) is configured system-wide by administrators in Admin → Settings.') }} - {{ _('Administrators can purchase a key to hide these prompts:') }} - {{ _('Support & Purchase Key') }}. + {{ _('Supporter status is configured instance-wide by administrators in Admin → Settings.') }} + {{ _('Anyone can become a Supporter:') }} + {{ _('Support TimeTracker') }}.
diff --git a/app/utils/context_processors.py b/app/utils/context_processors.py index 6d01b384..aa830eb9 100644 --- a/app/utils/context_processors.py +++ b/app/utils/context_processors.py @@ -229,8 +229,12 @@ def inject_globals(): 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." + "Report exported — if TimeTracker saves you time, a coffee goes a long way." + ) + elif v == SupportPromptService.VARIANT_FIRST_INVOICE: + lp_message = _( + "You just sent your first invoice with TimeTracker — if the app is helping your " + "business, consider supporting its development." ) support_ui_json = json.dumps( @@ -259,8 +263,8 @@ def inject_globals(): "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." + "You've been tracking for a while today. TimeTracker is free because of " + "supporters like you." ), }, }, diff --git a/app/utils/email.py b/app/utils/email.py index 16960ee1..8dc5c22a 100644 --- a/app/utils/email.py +++ b/app/utils/email.py @@ -989,11 +989,28 @@ def send_invoice_email(invoice, recipient_email, sender_user=None, custom_messag db.session.add(invoice_email) # Update invoice status to 'sent' if it's still 'draft' - if invoice.status == "draft": + was_first_send = invoice.status == "draft" + if was_first_send: invoice.status = "sent" db.session.commit() + if was_first_send: + try: + from app.models import Invoice + + sent_statuses = ("sent", "paid", "overdue", "issued") + sent_count = ( + Invoice.query.filter( + Invoice.created_by == sender_id, Invoice.status.in_(sent_statuses) + ).count() + ) + from app.utils.support_invoice_sent import queue_first_invoice_support_prompt + + queue_first_invoice_support_prompt(sender_id, first_send=sent_count == 1) + except Exception: + pass + return True, invoice_email, f"Invoice email sent successfully to {recipient_email}" except Exception as e: diff --git a/app/utils/support_invoice_sent.py b/app/utils/support_invoice_sent.py new file mode 100644 index 00000000..2fb74c4c --- /dev/null +++ b/app/utils/support_invoice_sent.py @@ -0,0 +1,12 @@ +"""Hook first invoice send for support soft prompts.""" + +from __future__ import annotations + + +def queue_first_invoice_support_prompt(user_id: int, *, first_send: bool = False) -> None: + """Queue a one-shot support prompt when the user sends their first invoice.""" + from flask import session + + from app.services.support_prompt_service import SupportPromptService + + SupportPromptService.queue_first_invoice_prompt(session, user_id, first_send=first_send) diff --git a/tests/test_support_services.py b/tests/test_support_services.py index 67dcfbf2..b6e130b1 100644 --- a/tests/test_support_services.py +++ b/tests/test_support_services.py @@ -61,3 +61,47 @@ def test_pick_dashboard_skips_when_after_report_pending(): today_hours=8.0, ) assert picked is None + + +def test_pick_dashboard_hours_milestone(): + session = {} + user_stats = {"days_since_signup": 3, "time_entries_count": 50, "total_hours": 520.0} + picked = SupportPromptService.pick_dashboard_prompt( + session, + user_stats, + ui_show_donate=True, + is_supporter=False, + support_banner_suppressed=False, + today_hours=1.0, + ) + assert picked is not None + assert picked.get("variant") == SupportPromptService.VARIANT_HOURS_MILESTONE + assert picked.get("milestone") == 500 + + +def test_pick_dashboard_anniversary_30d(): + session = {} + user_stats = {"days_since_signup": 35, "time_entries_count": 10, "total_hours": 20.0} + picked = SupportPromptService.pick_dashboard_prompt( + session, + user_stats, + ui_show_donate=True, + is_supporter=False, + support_banner_suppressed=False, + today_hours=1.0, + ) + assert picked is not None + assert picked.get("variant") == SupportPromptService.VARIANT_ANNIVERSARY_30D + + +def test_consume_layout_prompt_first_invoice(): + session = {"support_prompt_trigger": SupportPromptService.VARIANT_FIRST_INVOICE} + 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_FIRST_INVOICE + assert session.get(SupportPromptService.SESSION_SOFT_PROMPT_CONSUMED) is True From 8189ac94fd4e9e70b2364aa42d123e2f4ecb4869 Mon Sep 17 00:00:00 2001 From: Dries Peeters