diff --git a/backend/src/main/java/net/modtale/model/resources/Mod.java b/backend/src/main/java/net/modtale/model/resources/Mod.java index e94e29593..518a3effc 100644 --- a/backend/src/main/java/net/modtale/model/resources/Mod.java +++ b/backend/src/main/java/net/modtale/model/resources/Mod.java @@ -66,6 +66,7 @@ public class Mod { private List modIds; private boolean allowModpacks = true; private boolean allowReviews = true; + private boolean donationsEnabled = false; @Indexed private String status = "PUBLISHED"; @@ -155,6 +156,9 @@ public Mod() {} public boolean isAllowReviews() { return allowReviews; } public void setAllowReviews(boolean allowReviews) { this.allowReviews = allowReviews; } + public boolean isDonationsEnabled() { return donationsEnabled; } + public void setDonationsEnabled(boolean donationsEnabled) { this.donationsEnabled = donationsEnabled; } + public String getStatus() { return status; } public void setStatus(String status) { this.status = status; } public String getExpiresAt() { return expiresAt; } diff --git a/backend/src/main/java/net/modtale/model/user/User.java b/backend/src/main/java/net/modtale/model/user/User.java index 87b213f3b..d7f6e683e 100644 --- a/backend/src/main/java/net/modtale/model/user/User.java +++ b/backend/src/main/java/net/modtale/model/user/User.java @@ -78,6 +78,10 @@ public class User implements Serializable { private String gitlabRefreshToken; private LocalDateTime gitlabTokenExpiresAt; + private String stripeConnectId; + private int platformFeePercent = 10; + private List donationReminders = new ArrayList<>(); + public User() { this.tier = ApiKey.Tier.USER; this.createdAt = LocalDate.now().toString(); @@ -170,6 +174,26 @@ public ConnectedAccount(String provider, String providerId, String username, Str public void setVisible(boolean visible) { this.visible = visible; } } + public static class DonationReminder implements Serializable { + private static final long serialVersionUID = 1L; + private String modId; + private LocalDateTime remindAt; + private boolean sent = false; + + public DonationReminder() {} + public DonationReminder(String modId, LocalDateTime remindAt) { + this.modId = modId; + this.remindAt = remindAt; + } + + public String getModId() { return modId; } + public void setModId(String modId) { this.modId = modId; } + public LocalDateTime getRemindAt() { return remindAt; } + public void setRemindAt(LocalDateTime remindAt) { this.remindAt = remindAt; } + public boolean isSent() { return sent; } + public void setSent(boolean sent) { this.sent = sent; } + } + public String getId() { return id; } public void setId(String id) { this.id = id; } @@ -266,4 +290,13 @@ public List getBadges() { public LocalDateTime getGitlabTokenExpiresAt() { return gitlabTokenExpiresAt; } public void setGitlabTokenExpiresAt(LocalDateTime gitlabTokenExpiresAt) { this.gitlabTokenExpiresAt = gitlabTokenExpiresAt; } + + public String getStripeConnectId() { return stripeConnectId; } + public void setStripeConnectId(String stripeConnectId) { this.stripeConnectId = stripeConnectId; } + + public int getPlatformFeePercent() { return platformFeePercent; } + public void setPlatformFeePercent(int platformFeePercent) { this.platformFeePercent = platformFeePercent; } + + public List getDonationReminders() { return donationReminders; } + public void setDonationReminders(List donationReminders) { this.donationReminders = donationReminders; } } \ No newline at end of file diff --git a/backend/src/main/java/net/modtale/service/resources/ModService.java b/backend/src/main/java/net/modtale/service/resources/ModService.java index f0fb461ca..431de1895 100644 --- a/backend/src/main/java/net/modtale/service/resources/ModService.java +++ b/backend/src/main/java/net/modtale/service/resources/ModService.java @@ -8,6 +8,7 @@ import net.modtale.repository.resources.ModRepository; import net.modtale.repository.user.UserRepository; import net.modtale.service.AnalyticsService; +import net.modtale.service.security.EmailService; import net.modtale.service.security.SanitizationService; import net.modtale.service.security.FileValidationService; import net.modtale.service.security.WardenClientService; @@ -91,6 +92,7 @@ public class ModService { @Autowired private FileValidationService validationService; @Autowired private AnalyticsService analyticsService; @Autowired private NotificationService notificationService; + @Autowired private EmailService emailService; @Autowired private CacheManager cacheManager; @Autowired private WardenClientService wardenService; @Qualifier("taskExecutor") @@ -448,6 +450,7 @@ public Mod createDraft(String title, String description, String classification, mod.setVersions(new ArrayList<>()); mod.setAllowModpacks(true); mod.setAllowReviews(true); + mod.setDonationsEnabled(false); return modRepository.save(mod); } @@ -908,6 +911,7 @@ public void updateMod(String id, Mod updatedMod) { existing.setTypes(updatedMod.getTypes()); existing.setAllowModpacks(updatedMod.isAllowModpacks()); existing.setAllowReviews(updatedMod.isAllowReviews()); + existing.setDonationsEnabled(updatedMod.isDonationsEnabled()); if (updatedMod.getLinks() != null) existing.setLinks(updatedMod.getLinks()); if (updatedMod.getImageUrl() != null) existing.setImageUrl(updatedMod.getImageUrl()); @@ -1540,9 +1544,60 @@ public void incrementDownloadCount(String modId) { notificationService.sendNotification(List.of(author.getId()), title, msg, link, mod.getImageUrl()); } } + + if (mod.isDonationsEnabled()) { + taskExecutor.execute(() -> trackDownloadForReminder(modId, mod)); + } + } + } + + private void trackDownloadForReminder(String modId, Mod mod) { + User currentUser = userService.getCurrentUser(); + if (currentUser != null) { + boolean alreadyHasReminder = currentUser.getDonationReminders().stream() + .anyMatch(r -> r.getModId().equals(modId)); + + if (!alreadyHasReminder) { + User.DonationReminder reminder = new User.DonationReminder(modId, LocalDateTime.now().plusDays(7)); + Update update = new Update().push("donationReminders", reminder); + mongoTemplate.updateFirst(Query.query(Criteria.where("id").is(currentUser.getId())), update, User.class); + } } } + @Scheduled(cron = "0 0 10 * * ?") // Every day at 10 AM + public void processDonationReminders() { + LocalDateTime now = LocalDateTime.now(); + Query query = Query.query(Criteria.where("donationReminders").elemMatch( + Criteria.where("remindAt").lte(now).and("sent").is(false) + )); + + // Limit processing to prevent timeouts, using a stream to handle potentially large sets efficiently + mongoTemplate.stream(query, User.class).forEach(user -> { + boolean updated = false; + for (User.DonationReminder reminder : user.getDonationReminders()) { + if (!reminder.isSent() && reminder.getRemindAt().isBefore(now)) { + Mod mod = getRawModById(reminder.getModId()); + if (mod != null && mod.isDonationsEnabled()) { + // Use notificationService instead of emailService + notificationService.sendNotification( + List.of(user.getId()), + "Enjoying " + mod.getTitle() + "?", + "Consider supporting " + mod.getAuthor() + " with a donation!", + getProjectLink(mod) + "?donate=true", + mod.getImageUrl() + ); + } + reminder.setSent(true); + updated = true; + } + } + if (updated) { + userRepository.save(user); + } + }); + } + private void notifyUpdates(Mod mod, String versionNumber) { taskExecutor.execute(() -> { try { @@ -1878,6 +1933,10 @@ public void incrementDownloadCountByFileUrl(String fileUrl) { modRepository.save(mod); evictProjectDetails(mod); analyticsService.logDownload(mod.getId(), null, mod.getAuthor(), false, "internal"); + + if (mod.isDonationsEnabled()) { + taskExecutor.execute(() -> trackDownloadForReminder(mod.getId(), mod)); + } } } diff --git a/frontend/src/components/dashboard/ManageProfile.tsx b/frontend/src/components/dashboard/ManageProfile.tsx index a9d3f5a94..0600bf216 100644 --- a/frontend/src/components/dashboard/ManageProfile.tsx +++ b/frontend/src/components/dashboard/ManageProfile.tsx @@ -1,7 +1,29 @@ import React, { useState, useEffect } from 'react'; import { useSearchParams } from 'react-router-dom'; import { api, BACKEND_URL } from '../../utils/api'; -import { Save, Github, Twitter, Check, Eye, EyeOff, Trash2, Plus, Link, AlertTriangle, Edit3, XCircle, Mail, ShieldCheck, ShieldAlert, Key, Smartphone, Lock } from 'lucide-react'; +import { + Save, + Github, + Twitter, + Check, + Eye, + EyeOff, + Trash2, + Plus, + Link, + AlertTriangle, + Edit3, + XCircle, + Mail, + ShieldCheck, + ShieldAlert, + Key, + Smartphone, + Lock, + CreditCard, + DollarSign, + ChevronRight +} from 'lucide-react'; import type { User as UserType } from '../../types'; import { Spinner } from '../ui/Spinner'; import { ErrorBanner } from '../ui/error/ErrorBanner.tsx'; @@ -24,6 +46,10 @@ const GoogleIcon = ({ className }: { className?: string }) => ( ); +const StripeIcon = ({ className }: { className?: string }) => ( + +); + interface ManageProfileProps { user: UserType; onUpdate: () => void; @@ -58,6 +84,10 @@ export const ManageProfile: React.FC = ({ user, onUpdate }) const [mfaCode, setMfaCode] = useState(''); const [mfaLoading, setMfaLoading] = useState(false); + const [platformFee, setPlatformFee] = useState(user.platformFeePercent || 10); + const [savingFee, setSavingFee] = useState(false); + const [feeSaved, setFeeSaved] = useState(false); + const accounts = user.connectedAccounts || []; useEffect(() => { @@ -223,6 +253,20 @@ export const ManageProfile: React.FC = ({ user, onUpdate }) } }; + const handleSaveFee = async () => { + setSavingFee(true); + try { + await api.put('/user/monetization/fee', { percent: platformFee }); + setFeeSaved(true); + setTimeout(() => setFeeSaved(false), 2000); + onUpdate(); + } catch (e) { + setError("Failed to update platform fee."); + } finally { + setSavingFee(false); + } + }; + const AccountRow = ({ provider, icon: Icon, label }: { provider: string, icon: any, label: string }) => { const account = accounts.find(a => a.provider === provider); const isLinked = !!account; @@ -311,6 +355,87 @@ export const ManageProfile: React.FC = ({ user, onUpdate }) bioInput={bioInput} /> +
+
+
+ +

Monetization

+
+ +
+
+
+

+ + Payout Account +

+

+ Link your Stripe account to receive donations from users who download your mods. + We use Stripe Connect to handle secure payouts directly to your bank. +

+
+
+ {user.stripeConnectId ? ( +
+
+ +
+
+

Connected

+

{user.stripeConnectId}

+
+
+ ) : ( + + )} +
+
+ + {user.stripeConnectId && ( +
+
+
+

Platform Support

+

+ Choose what percentage of donations goes to support Modtale hosting costs. +

+
+ {platformFee}% +
+
+ setPlatformFee(parseInt(e.target.value))} + className="flex-1 h-2 bg-slate-200 dark:bg-white/10 rounded-lg appearance-none cursor-pointer accent-modtale-accent" + /> + +
+
+ 0% + Default (10%) + 30% +
+
+ )} +
+
+
+
diff --git a/frontend/src/components/resources/mod-detail/DownloadDialogs.tsx b/frontend/src/components/resources/mod-detail/DownloadDialogs.tsx index c6064c98f..cd07985ca 100644 --- a/frontend/src/components/resources/mod-detail/DownloadDialogs.tsx +++ b/frontend/src/components/resources/mod-detail/DownloadDialogs.tsx @@ -1,6 +1,6 @@ import React, { useState, useEffect, useRef } from 'react'; import type { ProjectVersion } from '../../../types'; -import { Download, X, ChevronDown, ChevronUp, Link as LinkIcon, List, AlertCircle, FileText, ChevronRight } from 'lucide-react'; +import { Download, X, ChevronDown, ChevronUp, Link as LinkIcon, List, AlertCircle, FileText, ChevronRight, Heart, Mail, Check, CreditCard, Bell } from 'lucide-react'; import { formatTimeAgo, ChannelBadge, compareSemVer } from '../../../utils/modHelpers'; import ReactMarkdown from 'react-markdown'; import rehypeRaw from 'rehype-raw'; @@ -27,20 +27,102 @@ const useScrollLock = (lock: boolean) => { }, [lock]); }; +interface DonationPromptProps { + modId: string; + modTitle: string; + authorName: string; + onClose: () => void; + isLoggedIn?: boolean; +} + +const DonationPrompt: React.FC = ({ modId, modTitle, authorName, onClose, isLoggedIn }) => { + const donationAmounts = [3, 5, 10, 20]; + + return ( +
+
+

+ Support the Creator +

+ +
+ +
+
+

Enjoying {modTitle}?

+

+ Creating content takes time and effort. Consider supporting {authorName} to help them keep building amazing things. +

+
+ +
+ {donationAmounts.map(amount => ( + + ))} +
+ + + + {isLoggedIn && ( +
+
+
+ +
+
+
+ 7-Day Reminder +
+

+ We'll send you a notification in a week to remind you about donating if you're still enjoying the mod. +

+
+
+
+ )} +
+ +
+ +
+
+ ); +}; + interface DependencyModalProps { dependencies: NonNullable; onClose: () => void; onConfirm: () => void; + modTitle: string; + modId: string; + authorName: string; + donationsEnabled?: boolean; + isLoggedIn?: boolean; } interface MetaCache { [key: string]: { title: string; author: string; icon: string }; } -export const DependencyModal: React.FC = ({ dependencies, onClose, onConfirm }) => { +export const DependencyModal: React.FC = ({ dependencies, onClose, onConfirm, modTitle, modId, authorName, donationsEnabled, isLoggedIn }) => { useScrollLock(true); const [selected, setSelected] = useState>(new Set(dependencies.map(d => d.modId))); const [metaCache, setMetaCache] = useState({}); + const [showDonation, setShowDonation] = useState(false); useEffect(() => { const fetchMeta = async () => { @@ -96,6 +178,12 @@ export const DependencyModal: React.FC = ({ dependencies, } }); onConfirm(); + + if (donationsEnabled) { + setShowDonation(true); + } else { + onClose(); + } }; const getIconUrl = (path?: string) => { @@ -106,86 +194,98 @@ export const DependencyModal: React.FC = ({ dependencies, return (
-
-

- Dependencies -

- -
- -
-
-
- Select dependencies to download automatically. + {showDonation ? ( + + ) : ( + <> +
+

+ Dependencies +

+
- -
-
- {dependencies.map(dep => { - const meta = metaCache[dep.modId]; - const isSelected = selected.has(dep.modId); +
+
+
+ Select dependencies to download automatically. +
+ +
- return ( -
-
- toggleDep(dep.modId)} - className="w-5 h-5 rounded text-modtale-accent focus:ring-modtale-accent border-slate-600 bg-slate-800 cursor-pointer flex-shrink-0" - /> - e.currentTarget.src='/assets/favicon.svg'} - /> -
toggleDep(dep.modId)}> -
- {meta?.title || dep.modTitle || dep.modId} +
+ {dependencies.map(dep => { + const meta = metaCache[dep.modId]; + const isSelected = selected.has(dep.modId); + + return ( +
+
+ toggleDep(dep.modId)} + className="w-5 h-5 rounded text-modtale-accent focus:ring-modtale-accent border-slate-600 bg-slate-800 cursor-pointer flex-shrink-0" + /> + e.currentTarget.src='/assets/favicon.svg'} + /> +
toggleDep(dep.modId)}> +
+ {meta?.title || dep.modTitle || dep.modId} +
+
+ by {meta?.author || '...'} + + v{dep.versionNumber} +
+
-
- by {meta?.author || '...'} - - v{dep.versionNumber} +
+ {!dep.isOptional && Required} + {dep.isOptional && Optional}
-
-
- {!dep.isOptional && Required} - {dep.isOptional && Optional} -
-
- ); - })} -
+ ); + })} +
- {missingRequired && ( -
- -

Some Required dependencies are unchecked.

+ {missingRequired && ( +
+ +

Some Required dependencies are unchecked.

+
+ )}
- )} -
-
- - -
+
+ + +
+ + )}
); @@ -245,10 +345,26 @@ const CustomDropdown = ({ options, value, onChange, placeholder }: any) => { ); }; -export const DownloadModal: React.FC = ({ show, onClose, versionsByGame, onDownload, showExperimental, onToggleExperimental, onViewHistory }) => { +interface DownloadModalProps { + show: boolean; + onClose: () => void; + versionsByGame: any; + onDownload: (url: string, version: string, deps: any[]) => void; + showExperimental: boolean; + onToggleExperimental: () => void; + onViewHistory: () => void; + modTitle: string; + modId: string; + authorName: string; + donationsEnabled?: boolean; + isLoggedIn?: boolean; +} + +export const DownloadModal: React.FC = ({ show, onClose, versionsByGame, onDownload, showExperimental, onToggleExperimental, onViewHistory, modTitle, modId, authorName, donationsEnabled, isLoggedIn }) => { useScrollLock(show); const [selectedGameVer, setSelectedGameVer] = useState(''); const [isListExpanded, setIsListExpanded] = useState(false); + const [showDonation, setShowDonation] = useState(false); useEffect(() => { if (show) { @@ -256,6 +372,7 @@ export const DownloadModal: React.FC = ({ show, onClose, versionsByGame, on if (keys.length > 0 && (!selectedGameVer || !keys.includes(selectedGameVer))) { setSelectedGameVer(keys[0]); } + setShowDonation(false); } }, [show, versionsByGame]); @@ -271,6 +388,15 @@ export const DownloadModal: React.FC = ({ show, onClose, versionsByGame, on if (!show) return null; + const handleDownloadClick = (url: string, version: string, deps: any[]) => { + onDownload(url, version, deps); + if (donationsEnabled) { + setShowDonation(true); + } else { + onClose(); + } + }; + const gameVersions = Object.keys(versionsByGame).sort((a, b) => b.localeCompare(a, undefined, { numeric: true })); const currentVersions = versionsByGame[selectedGameVer] || []; @@ -296,91 +422,103 @@ export const DownloadModal: React.FC = ({ show, onClose, versionsByGame, on
e.stopPropagation()}> -
-
-

Download

-
-
-
+ {showDonation ? ( + + ) : ( + <> +
+
+

Download

+
+
+
+
+ Show Beta/Alpha +
- Show Beta/Alpha +
-
- -
- -
-
- - -
- - {latestVer ? ( - <> - -
-
-
Other Versions
+
+
+ +
- + {latestVer ? ( + <> + - {isListExpanded && ( -
- {sortedVersions.map((ver: any) => ( -
-
-
-
-
v{ver.versionNumber}
-
{formatTimeAgo(ver.releaseDate)}
+
+
+
Other Versions
+
+ + + + {isListExpanded && ( +
+ {sortedVersions.map((ver: any) => ( +
+
+
+
+
v{ver.versionNumber}
+
{formatTimeAgo(ver.releaseDate)}
+
+
+
-
- + ))}
- ))} + )} + + ) : ( +
+ +

No compatible versions found.

+ {!showExperimental && currentVersions.length > 0 && ( + + )}
)} - - ) : ( -
- -

No compatible versions found.

- {!showExperimental && currentVersions.length > 0 && ( - - )}
- )} -
-
- -
+
+ +
+ + )}
); diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 04a2c81cb..1a1802d6f 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -37,6 +37,8 @@ export interface User { tier?: string; accountType?: 'USER' | 'ORGANIZATION'; organizationMembers?: OrganizationMember[]; + stripeConnectId?: string; + platformFeePercent?: number; } export interface ModDependency { @@ -124,6 +126,7 @@ export interface Mod { lastTrendingNotification?: string; allowModpacks?: boolean; allowReviews?: boolean; + donationsEnabled?: boolean; status?: 'DRAFT' | 'PENDING' | 'PUBLISHED' | 'UNLISTED' | 'DELETED' | 'ARCHIVED'; expiresAt?: string; }