Merge pull request #370 from DRYTRIX/rc/v4.8.3

Rc/v4.8.3
This commit is contained in:
Dries Peeters
2025-12-30 09:53:09 +01:00
committed by GitHub
10 changed files with 727 additions and 30 deletions

View File

@@ -80,6 +80,7 @@ from .link_template import LinkTemplate
from .custom_field_definition import CustomFieldDefinition
from .salesman_email_mapping import SalesmanEmailMapping
from .issue import Issue
from .donation_interaction import DonationInteraction
__all__ = [
"User",
@@ -185,4 +186,5 @@ __all__ = [
"CustomFieldDefinition",
"SalesmanEmailMapping",
"Issue",
"DonationInteraction",
]

View File

@@ -0,0 +1,98 @@
"""Model to track donation banner interactions and user engagement metrics"""
from datetime import datetime, timedelta
from app import db
class DonationInteraction(db.Model):
"""Track user interactions with donation prompts"""
__tablename__ = "donation_interactions"
id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False, index=True)
# Interaction type
interaction_type = db.Column(
db.String(50), nullable=False
) # 'banner_dismissed', 'banner_clicked', 'link_clicked', 'page_viewed'
# Context
source = db.Column(db.String(100), nullable=True) # 'dashboard', 'banner', 'menu', 'footer', etc.
# User metrics at time of interaction (for smart prompts)
time_entries_count = db.Column(db.Integer, nullable=True) # Total time entries
days_since_signup = db.Column(db.Integer, nullable=True) # Days since user created account
total_hours = db.Column(db.Float, nullable=True) # Total hours tracked
# Timestamps
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
# Relationships
user = db.relationship("User", backref="donation_interactions")
def __repr__(self):
return f"<DonationInteraction {self.interaction_type} by user {self.user_id}>"
@staticmethod
def record_interaction(user_id: int, interaction_type: str, source: str = None, user_metrics: dict = None):
"""Record a donation interaction"""
interaction = DonationInteraction(
user_id=user_id,
interaction_type=interaction_type,
source=source,
)
if user_metrics:
interaction.time_entries_count = user_metrics.get("time_entries_count")
interaction.days_since_signup = user_metrics.get("days_since_signup")
interaction.total_hours = user_metrics.get("total_hours")
db.session.add(interaction)
db.session.commit()
return interaction
@staticmethod
def has_recent_donation_click(user_id: int, days: int = 30) -> bool:
"""Check if user clicked donation link in last N days"""
cutoff = datetime.utcnow() - timedelta(days=days)
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()
is not None
)
@staticmethod
def get_user_engagement_metrics(user_id: int) -> dict:
"""Get user engagement metrics for smart prompts"""
from app.models import TimeEntry, User
user = User.query.get(user_id)
if not user:
return {}
# Days since signup
days_since_signup = (datetime.utcnow() - user.created_at).days if user.created_at else 0
# Time entries count
time_entries_count = TimeEntry.query.filter_by(user_id=user_id).count()
# Total hours
total_hours = user.total_hours if hasattr(user, "total_hours") else 0.0
return {
"days_since_signup": days_since_signup,
"time_entries_count": time_entries_count,
"total_hours": total_hours,
}

View File

@@ -105,6 +105,25 @@ def dashboard():
# Get recent activities for activity feed widget
recent_activities = Activity.get_recent(user_id=None if current_user.is_admin else current_user.id, limit=10)
# Get user stats for smart banner and donation widget
try:
from app.models import DonationInteraction
user_stats = DonationInteraction.get_user_engagement_metrics(current_user.id)
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
time_entries_count = TimeEntry.query.filter_by(user_id=current_user.id).count()
total_hours = current_user.total_hours if hasattr(current_user, "total_hours") else 0.0
user_stats = {
"days_since_signup": days_since_signup,
"time_entries_count": time_entries_count,
"total_hours": total_hours,
}
# Get donation widget stats (separate from user_stats for clarity)
time_entries_count = user_stats.get("time_entries_count", 0)
total_hours = user_stats.get("total_hours", 0.0)
# Prepare template data
template_data = {
"active_timer": active_timer,
@@ -118,6 +137,9 @@ def dashboard():
"current_week_goal": current_week_goal,
"templates": templates,
"recent_activities": recent_activities,
"user_stats": user_stats, # For smart banner
"time_entries_count": time_entries_count, # For donation widget
"total_hours": total_hours, # For donation widget
}
# Cache for 5 minutes
@@ -154,6 +176,93 @@ def help():
return render_template("main/help.html")
@main_bp.route("/donate")
@login_required
def donate():
"""Donation page explaining why donations are important"""
from app.models import TimeEntry
# Get user engagement metrics
days_since_signup = (datetime.utcnow() - current_user.created_at).days if current_user.created_at else 0
time_entries_count = TimeEntry.query.filter_by(user_id=current_user.id).count()
total_hours = current_user.total_hours if hasattr(current_user, "total_hours") else 0.0
# Record page view (only if table exists)
try:
from app.models import DonationInteraction
DonationInteraction.record_interaction(
user_id=current_user.id,
interaction_type="page_viewed",
source="donate_page",
user_metrics={
"days_since_signup": days_since_signup,
"time_entries_count": time_entries_count,
"total_hours": total_hours,
}
)
except Exception:
# Don't fail if tracking fails (e.g., table doesn't exist yet)
pass
return render_template(
"main/donate.html",
days_since_signup=days_since_signup,
time_entries_count=time_entries_count,
total_hours=total_hours,
)
@main_bp.route("/donate/track-click", methods=["POST"])
@login_required
def track_donation_click():
"""Track donation link clicks"""
try:
from app.models import DonationInteraction
data = request.get_json() or {}
source = data.get("source", "unknown")
# Get user metrics
metrics = DonationInteraction.get_user_engagement_metrics(current_user.id)
# Record click
DonationInteraction.record_interaction(
user_id=current_user.id,
interaction_type="link_clicked",
source=source,
user_metrics=metrics,
)
return jsonify({"success": True})
except Exception as e:
# Return success even if tracking fails (e.g., table doesn't exist yet)
return jsonify({"success": True, "note": "Tracking unavailable"})
@main_bp.route("/donate/track-banner-dismissal", methods=["POST"])
@login_required
def track_banner_dismissal():
"""Track banner dismissals"""
try:
from app.models import DonationInteraction
# Get user metrics
metrics = DonationInteraction.get_user_engagement_metrics(current_user.id)
# Record dismissal
DonationInteraction.record_interaction(
user_id=current_user.id,
interaction_type="banner_dismissed",
source="banner",
user_metrics=metrics,
)
return jsonify({"success": True})
except Exception as e:
# Return success even if tracking fails (e.g., table doesn't exist yet)
return jsonify({"success": True, "note": "Tracking unavailable"})
@main_bp.route("/debug/i18n")
@login_required
def debug_i18n():

View File

@@ -865,9 +865,9 @@
</a>
</li>
<li class="mt-2">
<a href="https://buymeacoffee.com/DryTrix" target="_blank" rel="noopener noreferrer" class="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">
<a href="{{ url_for('main.donate') }}" class="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">
<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">{{ _('Buy me a coffee') }}</span>
<span class="ml-3 sidebar-label font-medium">{{ _('Support Development') }}</span>
<span class="ml-auto text-xs opacity-70"></span>
</a>
</li>
@@ -907,9 +907,10 @@
<!-- Right side controls -->
<div class="flex items-center space-x-4">
<!-- BuyMeACoffee Button -->
<a href="https://buymeacoffee.com/DryTrix"
<a href="https://buymeacoffee.com/DryTrix?utm_source=timetracker&utm_medium=header&utm_campaign=support"
target="_blank"
rel="noopener noreferrer"
onclick="trackDonationClick('header')"
class="hidden md:flex items-center gap-2 px-3 py-1.5 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 text-sm font-medium"
title="{{ _('Support TimeTracker development') }}">
<i class="fas fa-mug-saucer"></i>
@@ -972,6 +973,7 @@
</li>
<li><a href="{{ url_for('auth.profile') }}" 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-user w-4"></i> {{ _('My Profile') }}</a></li>
<li><a href="{{ url_for('user.settings') }}" 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-cog w-4"></i> {{ _('My Settings') }}</a></li>
<li><a href="{{ url_for('main.donate') }}" class="flex items-center gap-2 px-4 py-2 text-sm text-amber-600 dark:text-amber-400 hover:bg-gray-100 dark:hover:bg-gray-700"><i class="fas fa-mug-saucer w-4"></i> {{ _('Support Development') }}</a></li>
<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>
</div>
@@ -995,20 +997,25 @@
<div class="flex items-center gap-3 flex-1">
<i class="fas fa-mug-saucer text-amber-600 dark:text-amber-400 text-lg"></i>
<div class="flex-1">
<p class="text-sm font-medium text-amber-900 dark:text-amber-100">
<p class="text-sm font-medium text-amber-900 dark:text-amber-100" id="bannerTitle">
{{ _('Enjoying TimeTracker?') }}
</p>
<p class="text-xs text-amber-700 dark:text-amber-300">
{{ _('Support continued development with a coffee') }} ☕
<p class="text-xs text-amber-700 dark:text-amber-300" id="bannerMessage">
{{ _('Your support helps fund server costs and new features') }} ☕
</p>
</div>
</div>
<div class="flex items-center gap-2">
<a href="https://buymeacoffee.com/DryTrix"
<a href="{{ url_for('main.donate') }}"
class="px-3 py-1.5 bg-amber-600 hover:bg-amber-700 text-white text-sm font-medium rounded-lg transition-colors">
{{ _('Learn More') }}
</a>
<a href="https://buymeacoffee.com/DryTrix?utm_source=timetracker&utm_medium=banner&utm_campaign=support"
target="_blank"
rel="noopener noreferrer"
class="px-3 py-1.5 bg-amber-600 hover:bg-amber-700 text-white text-sm font-medium rounded-lg transition-colors">
{{ _('Support') }}
onclick="trackDonationClick('banner')"
class="px-3 py-1.5 bg-white hover:bg-amber-50 text-amber-600 text-sm font-medium rounded-lg transition-colors border border-amber-600">
{{ _('Donate') }}
</a>
<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"
@@ -1023,6 +1030,25 @@
<main id="mainContentAnchor" class="flex-1 p-6">
{% block content %}{% endblock %}
</main>
<!-- Footer -->
<footer class="bg-card-light dark:bg-card-dark border-t border-border-light dark:border-border-dark py-4 px-6 mt-auto">
<div class="max-w-7xl mx-auto flex flex-col md:flex-row justify-between items-center gap-4 text-sm text-text-muted-light dark:text-text-muted-dark">
<div class="flex items-center gap-4 flex-wrap justify-center md:justify-start">
<span>&copy; <span id="currentYear"></span> TimeTracker</span>
<a href="{{ url_for('main.about') }}" class="hover:text-text-light dark:hover:text-text-dark">{{ _('About') }}</a>
<a href="{{ url_for('main.help') }}" class="hover:text-text-light dark:hover:text-text-dark">{{ _('Help') }}</a>
<a href="{{ url_for('main.donate') }}" class="text-amber-600 dark:text-amber-400 hover:text-amber-700 dark:hover:text-amber-300 font-medium">
<i class="fas fa-heart mr-1"></i>{{ _('Support') }}
</a>
</div>
<div class="flex items-center gap-2">
<a href="{{ url_for('main.donate') }}" class="px-3 py-1.5 bg-gradient-to-r from-amber-500 to-orange-500 hover:from-amber-600 hover:to-orange-600 text-white text-sm font-medium rounded-lg transition-all shadow-md hover:shadow-lg">
<i class="fas fa-mug-saucer mr-1"></i>{{ _('Donate') }}
</a>
</div>
</div>
</footer>
</div>
<!-- Mobile Bottom Navigation -->
@@ -1788,31 +1814,115 @@
// Close on Escape
document.addEventListener('keydown', function(e){ if (e.key === 'Escape') closeAllMenus(); });
// Support Banner Logic
// Support Banner Logic with Smart Prompts
function dismissSupportBanner() {
const banner = document.getElementById('supportBanner');
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 7 days)
// Store dismissal timestamp (show again after 30 days)
try {
localStorage.setItem('supportBannerDismissed', Date.now().toString());
// Track dismissal
trackBannerDismissal();
} catch(e) {}
}
}
function trackBannerDismissal() {
// Get CSRF token from meta tag
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || '';
fetch('{{ url_for("main.track_banner_dismissal") }}', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken
},
credentials: 'same-origin',
body: JSON.stringify({})
}).catch(() => {
// Silently fail if tracking doesn't work
});
}
function trackDonationClick(source) {
// Get CSRF token from meta tag
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || '';
fetch('{{ url_for("main.track_donation_click") }}', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken
},
credentials: 'same-origin',
body: JSON.stringify({
source: source
})
}).catch(() => {
// Silently fail if tracking doesn't work
});
}
function shouldShowSupportBanner() {
try {
// Check if dismissed recently (30 days)
const dismissed = localStorage.getItem('supportBannerDismissed');
if (!dismissed) return true;
const dismissedTime = parseInt(dismissed);
const sevenDays = 7 * 24 * 60 * 60 * 1000; // 7 days in milliseconds
return (Date.now() - dismissedTime) > sevenDays;
if (dismissed) {
const dismissedTime = parseInt(dismissed);
const thirtyDays = 30 * 24 * 60 * 60 * 1000; // 30 days in milliseconds
if ((Date.now() - dismissedTime) < thirtyDays) {
return false;
}
}
// Check if user clicked donation link recently (30 days)
const lastClick = localStorage.getItem('donationLinkClicked');
if (lastClick) {
const clickTime = parseInt(lastClick);
const thirtyDays = 30 * 24 * 60 * 60 * 1000;
if ((Date.now() - clickTime) < thirtyDays) {
return false;
}
}
return true;
} catch(e) {
return true; // Show by default if localStorage fails
}
}
function updateBannerMessage() {
// Get user stats from page if available
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 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
if (totalHours >= 100) {
bannerTitle.textContent = '{{ _("Amazing! You\'ve tracked over 100 hours") }}';
bannerMessage.textContent = '{{ _("Your support helps us keep TimeTracker free and improving") }} ☕';
} else if (timeEntriesCount >= 50) {
bannerTitle.textContent = '{{ _("Great progress! You\'ve logged 50+ entries") }}';
bannerMessage.textContent = '{{ _("Help us continue developing features you love") }} ☕';
} else if (daysSinceSignup >= 7) {
bannerTitle.textContent = '{{ _("Thanks for using TimeTracker!") }}';
bannerMessage.textContent = '{{ _("Your support helps fund server costs and new features") }} ☕';
} else {
// Default message
bannerTitle.textContent = '{{ _("Enjoying TimeTracker?") }}';
bannerMessage.textContent = '{{ _("Your support helps fund server costs and new features") }} ☕';
}
}
// Show support banner if conditions are met
// Check immediately to reserve space and prevent layout shift
(function() {
@@ -1820,6 +1930,9 @@
if (!banner) return;
if (shouldShowSupportBanner()) {
// Update banner message based on user stats
updateBannerMessage();
// Reserve space immediately by removing height constraints
// This prevents layout shift when banner becomes visible
banner.classList.remove('max-h-0', 'overflow-hidden');
@@ -1835,9 +1948,60 @@
banner.classList.add('max-h-0', 'overflow-hidden');
}
})();
// Track donation link clicks
document.addEventListener('click', function(e) {
const link = e.target.closest('a[href*="buymeacoffee.com"]');
if (link) {
try {
localStorage.setItem('donationLinkClicked', Date.now().toString());
} catch(e) {}
}
});
</script>
<script src="{{ url_for('static', filename='data-tables-enhanced.js') }}"></script>
<!-- User Stats for Smart Banner -->
{% if current_user.is_authenticated %}
<script>
window.userStats = {
days_since_signup: {{ user_stats.days_since_signup if user_stats else 0 }},
time_entries_count: {{ user_stats.time_entries_count if user_stats else 0 }},
total_hours: {{ user_stats.total_hours if user_stats else 0.0 }}
};
</script>
{% endif %}
<!-- Set current year in footer -->
<script>
document.getElementById('currentYear').textContent = new Date().getFullYear();
</script>
<!-- Global donation tracking function -->
<script>
function trackDonationClick(source) {
{% if current_user.is_authenticated %}
// Get CSRF token from meta tag
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || '';
fetch('{{ url_for("main.track_donation_click") }}', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken
},
credentials: 'same-origin',
body: JSON.stringify({
source: source
})
}).catch(() => {
// Silently fail if tracking doesn't work
});
{% endif %}
}
</script>
{% block scripts_extra %}{% endblock %}
</body>
</html>

View File

@@ -175,10 +175,35 @@
<a href="https://github.com/drytrix/TimeTracker" target="_blank" rel="noopener" class="px-4 py-2 rounded-lg border border-border-light dark:border-border-dark hover:bg-background-light dark:hover:bg-background-dark">
<i class="fab fa-github mr-2"></i>{{ _('View on GitHub') }}
</a>
<a href="https://buymeacoffee.com/DryTrix" 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-mug-saucer mr-2"></i>{{ _('Support Development') }}
<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-2"></i>{{ _('Support Development') }}
</a>
</div>
<!-- Support Section -->
<div class="bg-gradient-to-br from-amber-50 to-orange-50 dark:from-amber-900/20 dark:to-orange-900/20 p-6 rounded-lg border border-amber-200 dark:border-amber-800 mt-6">
<div class="flex items-start gap-4">
<div class="flex-shrink-0">
<i class="fas fa-mug-saucer text-3xl text-amber-600 dark:text-amber-400"></i>
</div>
<div class="flex-1">
<h3 class="text-lg font-semibold mb-2 text-amber-900 dark:text-amber-100">
{{ _('Support TimeTracker Development') }}
</h3>
<p class="text-sm text-amber-800 dark:text-amber-200 mb-4">
{{ _('TimeTracker is free and open-source. Your support helps fund server costs, new features, security updates, and keeps the project alive. Every contribution makes a difference!') }}
</p>
<div class="flex flex-wrap gap-2">
<a href="{{ url_for('main.donate') }}" class="px-4 py-2 bg-amber-600 hover:bg-amber-700 text-white rounded-lg font-medium transition-colors">
<i class="fas fa-info-circle mr-2"></i>{{ _('Learn More') }}
</a>
<a href="https://buymeacoffee.com/DryTrix?utm_source=timetracker&utm_medium=about_page&utm_campaign=support" target="_blank" rel="noopener" onclick="trackDonationClick('about_page')" class="px-4 py-2 bg-white hover:bg-amber-50 text-amber-600 rounded-lg font-medium transition-colors border border-amber-600">
<i class="fas fa-mug-saucer mr-2"></i>{{ _('Donate Now') }}
</a>
</div>
</div>
</div>
</div>
</div>
<!-- Getting Help -->

View File

@@ -264,7 +264,7 @@
</div>
</div>
<!-- BuyMeACoffee Widget -->
<!-- Support TimeTracker Widget -->
<div class="bg-gradient-to-br from-amber-500 via-orange-500 to-amber-600 p-6 rounded-lg shadow-lg dashboard-widget animated-card text-white">
<div class="flex items-start justify-between mb-3">
<div class="flex-1">
@@ -273,18 +273,36 @@
{{ _('Support TimeTracker') }}
</h2>
<p class="text-sm opacity-90 mb-4">
{{ _('Enjoying TimeTracker? Consider buying me a coffee to support continued development!') }}
{{ _('Your support helps fund server costs, new features, and keeps TimeTracker free for everyone.') }}
</p>
{% if time_entries_count > 0 or total_hours > 0 %}
<div class="text-xs opacity-75 mb-3 space-y-1">
{% if time_entries_count > 0 %}
<div><i class="fas fa-check-circle mr-1"></i>{{ _('You\'ve tracked %(count)s time entries', count=time_entries_count) }}</div>
{% endif %}
{% if total_hours > 0 %}
<div><i class="fas fa-check-circle mr-1"></i>{{ _('You\'ve logged %(hours)s hours', hours="%.1f"|format(total_hours)) }}</div>
{% endif %}
</div>
{% endif %}
</div>
</div>
<a href="https://buymeacoffee.com/DryTrix"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center justify-center w-full bg-white text-amber-600 px-4 py-3 rounded-lg font-semibold hover:bg-amber-50 transition-all duration-200 shadow-md hover:shadow-lg transform hover:-translate-y-0.5">
<i class="fas fa-mug-saucer mr-2"></i>
{{ _('Buy me a coffee') }}
<i class="fas fa-external-link-alt ml-2 text-xs"></i>
</a>
<div class="flex gap-2">
<a href="{{ url_for('main.donate') }}"
class="inline-flex items-center justify-center flex-1 bg-white text-amber-600 px-4 py-3 rounded-lg font-semibold hover:bg-amber-50 transition-all duration-200 shadow-md hover:shadow-lg transform hover:-translate-y-0.5">
<i class="fas fa-heart mr-2"></i>
{{ _('Learn More') }}
</a>
<a href="https://buymeacoffee.com/DryTrix?utm_source=timetracker&utm_medium=dashboard&utm_campaign=support"
target="_blank"
rel="noopener noreferrer"
onclick="trackDonationClick('dashboard_widget')"
class="inline-flex items-center justify-center flex-1 bg-amber-700 hover:bg-amber-800 text-white px-4 py-3 rounded-lg font-semibold transition-all duration-200 shadow-md hover:shadow-lg transform hover:-translate-y-0.5">
<i class="fas fa-mug-saucer mr-2"></i>
{{ _('Donate Now') }}
<i class="fas fa-external-link-alt ml-2 text-xs"></i>
</a>
</div>
</div>
</div>
<!-- Delete Entry Confirmation Dialogs -->

View File

@@ -0,0 +1,239 @@
{% extends "base.html" %}
{% block title %}{{ _('Support TimeTracker') }}{% endblock %}
{% block content %}
<div class="max-w-4xl mx-auto">
<!-- Breadcrumb -->
<nav class="mb-4 text-sm text-text-muted-light dark:text-text-muted-dark">
<a href="{{ url_for('main.dashboard') }}" class="hover:text-text-light dark:hover:text-text-dark">{{ _('Dashboard') }}</a>
<span class="mx-2">/</span>
<span class="text-text-light dark:text-text-dark">{{ _('Support Development') }}</span>
</nav>
<!-- Hero Section -->
<div class="bg-gradient-to-br from-amber-500 via-orange-500 to-amber-600 p-8 rounded-lg shadow-lg text-white mb-6">
<div class="text-center">
<i class="fas fa-mug-saucer text-6xl mb-4"></i>
<h1 class="text-4xl font-bold mb-4">{{ _('Support TimeTracker Development') }}</h1>
<p class="text-xl opacity-90 mb-6">
{{ _('Your support helps keep TimeTracker free and continuously improving') }}
</p>
<div class="flex flex-wrap gap-3 justify-center">
<a href="https://buymeacoffee.com/DryTrix?utm_source=timetracker&utm_medium=donate_page_hero&utm_campaign=support"
target="_blank"
rel="noopener noreferrer"
onclick="trackDonationClick('donate_page_hero')"
class="px-6 py-3 bg-white text-amber-600 rounded-lg font-semibold hover:bg-amber-50 transition-all shadow-lg hover:shadow-xl transform hover:-translate-y-0.5">
<i class="fas fa-mug-saucer mr-2"></i>{{ _('Donate Now') }}
<i class="fas fa-external-link-alt ml-2 text-xs"></i>
</a>
</div>
</div>
</div>
<!-- Why Donations Matter -->
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow border border-border-light dark:border-border-dark mb-6">
<h2 class="text-2xl font-semibold mb-4">
<i class="fas fa-heart text-rose-500 mr-2"></i>
{{ _('Why Your Support Matters') }}
</h2>
<div class="space-y-4 text-text-light dark:text-text-dark">
<p class="text-lg">
{{ _('TimeTracker is a free, open-source project built with passion and dedication. Your donations directly support:') }}
</p>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mt-6">
<div class="flex items-start gap-3">
<i class="fas fa-server text-primary mt-1"></i>
<div>
<h3 class="font-semibold mb-1">{{ _('Server Infrastructure') }}</h3>
<p class="text-sm text-text-muted-light dark:text-text-muted-dark">
{{ _('Hosting, databases, and CDN costs to keep TimeTracker fast and reliable') }}
</p>
</div>
</div>
<div class="flex items-start gap-3">
<i class="fas fa-code text-primary mt-1"></i>
<div>
<h3 class="font-semibold mb-1">{{ _('Feature Development') }}</h3>
<p class="text-sm text-text-muted-light dark:text-text-muted-dark">
{{ _('New features, improvements, and bug fixes based on your feedback') }}
</p>
</div>
</div>
<div class="flex items-start gap-3">
<i class="fas fa-shield-alt text-primary mt-1"></i>
<div>
<h3 class="font-semibold mb-1">{{ _('Security & Maintenance') }}</h3>
<p class="text-sm text-text-muted-light dark:text-text-muted-dark">
{{ _('Regular security updates, dependency maintenance, and performance optimization') }}
</p>
</div>
</div>
<div class="flex items-start gap-3">
<i class="fas fa-globe text-primary mt-1"></i>
<div>
<h3 class="font-semibold mb-1">{{ _('Internationalization') }}</h3>
<p class="text-sm text-text-muted-light dark:text-text-muted-dark">
{{ _('Translation support, localization, and making TimeTracker accessible worldwide') }}
</p>
</div>
</div>
</div>
</div>
</div>
<!-- Your Usage Stats -->
{% if time_entries_count > 0 or total_hours > 0 %}
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow border border-border-light dark:border-border-dark mb-6">
<h2 class="text-2xl font-semibold mb-4">
<i class="fas fa-chart-line text-primary mr-2"></i>
{{ _('Your TimeTracker Journey') }}
</h2>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
{% if days_since_signup > 0 %}
<div class="text-center p-4 bg-background-light dark:bg-background-dark rounded-lg">
<div class="text-3xl font-bold text-primary">{{ days_since_signup }}</div>
<div class="text-sm text-text-muted-light dark:text-text-muted-dark mt-1">
{{ _('Days using TimeTracker') }}
</div>
</div>
{% endif %}
{% if time_entries_count > 0 %}
<div class="text-center p-4 bg-background-light dark:bg-background-dark rounded-lg">
<div class="text-3xl font-bold text-primary">{{ time_entries_count }}</div>
<div class="text-sm text-text-muted-light dark:text-text-muted-dark mt-1">
{{ _('Time entries tracked') }}
</div>
</div>
{% endif %}
{% if total_hours > 0 %}
<div class="text-center p-4 bg-background-light dark:bg-background-dark rounded-lg">
<div class="text-3xl font-bold text-primary">{{ "%.1f"|format(total_hours) }}</div>
<div class="text-sm text-text-muted-light dark:text-text-muted-dark mt-1">
{{ _('Hours tracked') }}
</div>
</div>
{% endif %}
</div>
<p class="text-center mt-4 text-text-muted-light dark:text-text-muted-dark">
{{ _('Thank you for being part of the TimeTracker community!') }}
</p>
</div>
{% endif %}
<!-- Donation Options -->
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow border border-border-light dark:border-border-dark mb-6">
<h2 class="text-2xl font-semibold mb-4">
<i class="fas fa-hand-holding-heart text-amber-500 mr-2"></i>
{{ _('How to Support') }}
</h2>
<p class="mb-6 text-text-light dark:text-text-dark">
{{ _('Every contribution, no matter the size, makes a difference. Your support helps ensure TimeTracker remains free and continues to evolve.') }}
</p>
<div class="text-center">
<a href="https://buymeacoffee.com/DryTrix?utm_source=timetracker&utm_medium=donate_page&utm_campaign=support"
target="_blank"
rel="noopener noreferrer"
onclick="trackDonationClick('donate_page_button')"
class="inline-flex items-center justify-center gap-3 bg-gradient-to-r from-amber-500 to-orange-500 hover:from-amber-600 hover:to-orange-600 text-white px-8 py-4 rounded-lg font-semibold text-lg shadow-lg hover:shadow-xl transform hover:-translate-y-0.5 transition-all duration-200">
<i class="fas fa-mug-saucer text-2xl"></i>
<span>{{ _('Support on Buy Me a Coffee') }}</span>
<i class="fas fa-external-link-alt text-sm"></i>
</a>
</div>
<p class="text-center mt-4 text-sm text-text-muted-light dark:text-text-muted-dark">
{{ _('You\'ll be redirected to Buy Me a Coffee where you can choose your contribution amount') }}
</p>
</div>
<!-- Impact -->
<div class="bg-gradient-to-r from-green-50 to-emerald-50 dark:from-green-900/20 dark:to-emerald-900/20 p-6 rounded-lg border border-green-200 dark:border-green-800">
<h2 class="text-2xl font-semibold mb-4 text-green-900 dark:text-green-100">
<i class="fas fa-seedling mr-2"></i>
{{ _('The Impact of Your Support') }}
</h2>
<ul class="space-y-2 text-green-800 dark:text-green-200">
<li class="flex items-start gap-2">
<i class="fas fa-check-circle text-green-600 dark:text-green-400 mt-1"></i>
<span>{{ _('Enables faster development cycles and quicker feature releases') }}</span>
</li>
<li class="flex items-start gap-2">
<i class="fas fa-check-circle text-green-600 dark:text-green-400 mt-1"></i>
<span>{{ _('Supports better documentation and user guides') }}</span>
</li>
<li class="flex items-start gap-2">
<i class="fas fa-check-circle text-green-600 dark:text-green-400 mt-1"></i>
<span>{{ _('Helps maintain high-quality code and security standards') }}</span>
</li>
<li class="flex items-start gap-2">
<i class="fas fa-check-circle text-green-600 dark:text-green-400 mt-1"></i>
<span>{{ _('Keeps TimeTracker free and accessible for everyone') }}</span>
</li>
</ul>
</div>
<!-- Alternative Ways to Help -->
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow border border-border-light dark:border-border-dark mt-6">
<h2 class="text-2xl font-semibold mb-4">
<i class="fas fa-users text-primary mr-2"></i>
{{ _('Other Ways to Help') }}
</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<a href="https://github.com/drytrix/TimeTracker"
target="_blank"
rel="noopener noreferrer"
class="flex items-center gap-3 p-4 bg-background-light dark:bg-background-dark rounded-lg hover:bg-background-hover-light dark:hover:bg-background-hover-dark transition-colors">
<i class="fab fa-github text-2xl text-text-light dark:text-text-dark"></i>
<div>
<div class="font-semibold">{{ _('Contribute on GitHub') }}</div>
<div class="text-sm text-text-muted-light dark:text-text-muted-dark">
{{ _('Report bugs, suggest features, or submit code') }}
</div>
</div>
</a>
<a href="{{ url_for('main.help') }}"
class="flex items-center gap-3 p-4 bg-background-light dark:bg-background-dark rounded-lg hover:bg-background-hover-light dark:hover:bg-background-hover-dark transition-colors">
<i class="fas fa-book text-2xl text-text-light dark:text-text-dark"></i>
<div>
<div class="font-semibold">{{ _('Help Others') }}</div>
<div class="text-sm text-text-muted-light dark:text-text-muted-dark">
{{ _('Share your knowledge in the community') }}
</div>
</div>
</a>
</div>
</div>
</div>
<script>
function trackDonationClick(source) {
// Get CSRF token from meta tag
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || '';
// Track donation link click
fetch('{{ url_for("main.track_donation_click") }}', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken
},
credentials: 'same-origin',
body: JSON.stringify({
source: source
})
}).catch(() => {
// Silently fail if tracking doesn't work
});
}
</script>
{% endblock %}

View File

@@ -805,8 +805,8 @@
<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>
<a href="https://buymeacoffee.com/DryTrix" target="_blank" rel="noopener" class="px-4 py-2 rounded-lg border border-green-600 text-green-600 hover:bg-green-50 dark:hover:bg-green-900/20">
<i class="fas fa-mug-saucer mr-1"></i>{{ _('Support Development') }}
<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 Development') }}
</a>
</div>
</div>

View File

@@ -0,0 +1,42 @@
"""Add donation_interactions table
Revision ID: 094_add_donation_interactions
Revises: 093_remove_ui_allow_flags
Create Date: 2025-01-27 12:00:00
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision = "094_add_donation_interactions"
down_revision = "093_remove_ui_allow_flags"
branch_labels = None
depends_on = None
def upgrade():
"""Create donation_interactions table to track user interactions with donation prompts"""
op.create_table(
"donation_interactions",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("user_id", sa.Integer(), nullable=False),
sa.Column("interaction_type", sa.String(length=50), nullable=False),
sa.Column("source", sa.String(length=100), nullable=True),
sa.Column("time_entries_count", sa.Integer(), nullable=True),
sa.Column("days_since_signup", sa.Integer(), nullable=True),
sa.Column("total_hours", sa.Float(), nullable=True),
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.text("CURRENT_TIMESTAMP")),
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("idx_donation_interactions_user_id", "donation_interactions", ["user_id"])
def downgrade():
"""Drop donation_interactions table"""
op.drop_index("idx_donation_interactions_user_id", table_name="donation_interactions")
op.drop_table("donation_interactions")

View File

@@ -7,7 +7,7 @@ from setuptools import setup, find_packages
setup(
name='timetracker',
version='4.8.2',
version='4.8.3',
packages=find_packages(),
include_package_data=True,
install_requires=[