diff --git a/apps/frontend/src/components/ui/creator-payouts/distribute-earnings/AdjustmentsCard.vue b/apps/frontend/src/components/ui/creator-payouts/distribute-earnings/AdjustmentsCard.vue new file mode 100644 index 0000000000..e1dbc22d7c --- /dev/null +++ b/apps/frontend/src/components/ui/creator-payouts/distribute-earnings/AdjustmentsCard.vue @@ -0,0 +1,121 @@ + + + diff --git a/apps/frontend/src/components/ui/creator-payouts/distribute-earnings/DistributeBreakdownCard.vue b/apps/frontend/src/components/ui/creator-payouts/distribute-earnings/DistributeBreakdownCard.vue new file mode 100644 index 0000000000..a23ec7bd3a --- /dev/null +++ b/apps/frontend/src/components/ui/creator-payouts/distribute-earnings/DistributeBreakdownCard.vue @@ -0,0 +1,160 @@ + + + diff --git a/apps/frontend/src/components/ui/creator-payouts/distribute-earnings/DistributeBreakdownRow.vue b/apps/frontend/src/components/ui/creator-payouts/distribute-earnings/DistributeBreakdownRow.vue new file mode 100644 index 0000000000..8d72423062 --- /dev/null +++ b/apps/frontend/src/components/ui/creator-payouts/distribute-earnings/DistributeBreakdownRow.vue @@ -0,0 +1,53 @@ + + + diff --git a/apps/frontend/src/components/ui/creator-payouts/distribute-earnings/VerifyPayoutModal.vue b/apps/frontend/src/components/ui/creator-payouts/distribute-earnings/VerifyPayoutModal.vue new file mode 100644 index 0000000000..5a84bb4c3d --- /dev/null +++ b/apps/frontend/src/components/ui/creator-payouts/distribute-earnings/VerifyPayoutModal.vue @@ -0,0 +1,176 @@ + + + diff --git a/apps/frontend/src/components/ui/creator-payouts/distribute-earnings/index.vue b/apps/frontend/src/components/ui/creator-payouts/distribute-earnings/index.vue new file mode 100644 index 0000000000..5d738a266c --- /dev/null +++ b/apps/frontend/src/components/ui/creator-payouts/distribute-earnings/index.vue @@ -0,0 +1,151 @@ + + + diff --git a/apps/frontend/src/components/ui/creator-payouts/distribute-month-card/index.vue b/apps/frontend/src/components/ui/creator-payouts/distribute-month-card/index.vue new file mode 100644 index 0000000000..dfe71d7ce0 --- /dev/null +++ b/apps/frontend/src/components/ui/creator-payouts/distribute-month-card/index.vue @@ -0,0 +1,87 @@ + + + diff --git a/apps/frontend/src/components/ui/creator-payouts/payout-initiated-card/index.vue b/apps/frontend/src/components/ui/creator-payouts/payout-initiated-card/index.vue new file mode 100644 index 0000000000..aebca79c4d --- /dev/null +++ b/apps/frontend/src/components/ui/creator-payouts/payout-initiated-card/index.vue @@ -0,0 +1,66 @@ + + + diff --git a/apps/frontend/src/components/ui/creator-payouts/payouts-table/index.vue b/apps/frontend/src/components/ui/creator-payouts/payouts-table/index.vue new file mode 100644 index 0000000000..8c0cb726c7 --- /dev/null +++ b/apps/frontend/src/components/ui/creator-payouts/payouts-table/index.vue @@ -0,0 +1,424 @@ + + + + + diff --git a/apps/frontend/src/components/ui/creator-payouts/utils.ts b/apps/frontend/src/components/ui/creator-payouts/utils.ts new file mode 100644 index 0000000000..b60a8f5a44 --- /dev/null +++ b/apps/frontend/src/components/ui/creator-payouts/utils.ts @@ -0,0 +1,111 @@ +import type { Labrinth } from '@modrinth/api-client' + +export const CREATOR_PAYOUT_SHARE = 0.75 +export const MODRINTH_PAYOUT_SHARE = 0.25 + +export type PayoutHistoryItem = Labrinth.Payouts.Internal.HistoryItem +export type DistributionAdjustment = Labrinth.Payouts.Internal.DistributionAdjustment +export type DistributionRun = Labrinth.Payouts.Internal.DistributionRun + +export function isYearMonth(value: unknown): value is Labrinth.Payouts.Internal.YearMonth { + return typeof value === 'string' && /^\d{4}-\d{2}$/.test(value) +} + +export function formatMonthYear(yearMonth: string): string { + const date = getYearMonthDate(yearMonth) + return new Intl.DateTimeFormat(undefined, { + month: 'long', + year: 'numeric', + }).format(date) +} + +export function formatShortDate(date: Date): string { + return new Intl.DateTimeFormat(undefined, { + weekday: 'short', + month: 'short', + day: 'numeric', + }).format(date) +} + +export function formatCurrency(amount: number | null | undefined, options?: { cents?: boolean }) { + if (amount === null || amount === undefined || Number.isNaN(amount)) { + return '—' + } + + return new Intl.NumberFormat(undefined, { + style: 'currency', + currency: 'USD', + minimumFractionDigits: options?.cents ? 2 : 0, + maximumFractionDigits: options?.cents ? 2 : 0, + }).format(amount) +} + +export function formatSignedCurrency(amount: number | null | undefined): string { + if (amount === null || amount === undefined || Number.isNaN(amount)) { + return '—' + } + + const formatted = formatCurrency(Math.abs(amount)) + return amount < 0 ? `-${formatted}` : formatted +} + +export function getReviewDueDate(yearMonth: string): Date { + return addDays(getLastDayOfMonth(yearMonth), 75) +} + +export function getPendingAvailableDate(yearMonth: string): Date { + return addDays(getLastDayOfMonth(yearMonth), 60) +} + +export function getDaysRemaining(date: Date): number { + const today = new Date() + today.setHours(0, 0, 0, 0) + const target = new Date(date) + target.setHours(0, 0, 0, 0) + return Math.ceil((target.getTime() - today.getTime()) / 86_400_000) +} + +export function getNetActualRevenue( + amountReceived: number, + adjustments: DistributionAdjustment[], +): number { + return roundCurrency(amountReceived + getTotalAdjustments(adjustments)) +} + +export function getTotalAdjustments(adjustments: DistributionAdjustment[]): number { + return roundCurrency(adjustments.reduce((total, adjustment) => total + adjustment.amount, 0)) +} + +export function getCreatorShare(amount: number): number { + return roundCurrency(amount * CREATOR_PAYOUT_SHARE) +} + +export function getModrinthShare(amount: number): number { + return roundCurrency(amount * MODRINTH_PAYOUT_SHARE) +} + +export function getDistributionCreatorAmount(distribution: DistributionRun): number { + return getCreatorShare( + getNetActualRevenue(distribution.amount_received, distribution.adjustments), + ) +} + +export function roundCurrency(amount: number): number { + return Math.round(amount * 100) / 100 +} + +export function getYearMonthDate(yearMonth: string): Date { + const [year, month] = yearMonth.split('-').map(Number) + return new Date(year, month - 1, 1, 12) +} + +function getLastDayOfMonth(yearMonth: string): Date { + const [year, month] = yearMonth.split('-').map(Number) + return new Date(year, month, 0, 12) +} + +function addDays(date: Date, days: number): Date { + const nextDate = new Date(date) + nextDate.setDate(nextDate.getDate() + days) + return nextDate +} diff --git a/apps/frontend/src/layouts/default.vue b/apps/frontend/src/layouts/default.vue index 8670de42a0..7b7ec3adbc 100644 --- a/apps/frontend/src/layouts/default.vue +++ b/apps/frontend/src/layouts/default.vue @@ -481,6 +481,14 @@ to: '/admin/analytics/events', shown: isAdmin(auth.user), }, + { + id: 'creator-payouts', + label: 'Creator payouts', + icon: BadgeDollarSignIcon, + type: 'link', + to: '/admin/creator-payouts', + shown: isAdmin(auth.user), + }, { type: 'divider' }, { id: 'email-templates', @@ -806,6 +814,7 @@ import { AffiliateIcon, ArrowBigUpDashIcon, ArrowLeftRightIcon, + BadgeDollarSignIcon, BellIcon, BookOpenIcon, BoxIcon, diff --git a/apps/frontend/src/pages/admin/creator-payouts/distribute.vue b/apps/frontend/src/pages/admin/creator-payouts/distribute.vue new file mode 100644 index 0000000000..ca007c66c6 --- /dev/null +++ b/apps/frontend/src/pages/admin/creator-payouts/distribute.vue @@ -0,0 +1,109 @@ + + + diff --git a/apps/frontend/src/pages/admin/creator-payouts/index.vue b/apps/frontend/src/pages/admin/creator-payouts/index.vue new file mode 100644 index 0000000000..895a6971ea --- /dev/null +++ b/apps/frontend/src/pages/admin/creator-payouts/index.vue @@ -0,0 +1,135 @@ + + + diff --git a/apps/frontend/src/public/news/feed/rss.xml b/apps/frontend/src/public/news/feed/rss.xml index abde87b8cb..0b74a3868e 100644 --- a/apps/frontend/src/public/news/feed/rss.xml +++ b/apps/frontend/src/public/news/feed/rss.xml @@ -4,7 +4,7 @@ https://modrinth.com/news/ @modrinth/blog - Tue, 18 Aug 2026 05:31:31 GMT + Wed, 02 Sep 2026 18:35:56 GMT @@ -115,7 +115,7 @@ https://modrinth.com/news/article/streamlined-version-creation/ https://modrinth.com/news/article/streamlined-version-creation/ Thu, 18 Dec 2025 20:50:00 GMT - <![CDATA[<p>Hey everyone! As part of our ongoing work to improve the creator side of the platform, we’re shipping a new project version creation and editing today. This part of the product was showing its age, so we’ve overhauled it to set us up for the new project types we plan to ship in the new year!</p><h2>TL;DR</h2><ul><li>Multi-file uploads with primary file detection and new supplementary file types</li><li>Automatic detection of version number, subtitle, loaders, game versions, and environment bundled into a version summary</li><li>A new loader selector that groups loaders by project type</li><li>A new game version selector with search and selecting version ranges</li><li>Project environments moved to be on a per-version basis</li><li>A new dependency selector with search and smart suggestions</li><li>Project gallery, versions, and publishing checklist moved into project settings</li></ul><h2>File uploading</h2><p>For starters, we’ve been centralizing all project editing into Project Settings to make the experience clearer and more approachable for new creators. Editing project versions now happens directly within Project Settings and projects look slightly different if you’re the creator.</p><p><img src="/news/article/streamlined-version-creation/edit-button.webp" alt="Project page header showing the primary action as &quot;Edit project&quot; for the creator"></p><p>You can create a new version by drag and dropping files into the versions table or creating a new version and uploading them. Multiple files can be uploaded at once.</p><p>The primary file is explicitly listed at the top and separate from any supplementary files. From there, you can add additional supplementary files and assign their types. Newly supported types include sources jar, dev jar, javadoc jar, and signature file.</p><div class="video-wrapper mb-8"><video autoplay loop muted playsinline><source src="https://cdn-raw.modrinth.com/blog/streamlined-version-creation/vid1.mp4" type="video/mp4"></video></div><h2>Version summary</h2><p>Once you’ve uploaded your files, you’re taken to a summary page where we automatically detect the version number, subtitle, loaders, game versions, and environments based on the primary file and previous project versions.</p><p>Any field can be individually edited by clicking the edit button in the top right. For cases where we’re unable to detect something, that field simply won’t appear in the summary and will instead show up as an additional step in the modal flow.</p><p><img src="/news/article/streamlined-version-creation/details.webp" alt="Add details stage of the upload modal, where the user selects version type, number, subtitle, and can edit loaders, game versions, and environment metadata."></p><h2>Loader selector</h2><p>We’ve added a refreshed loader selection screen that groups loaders by project type. You can click any loader tag to add it.</p><div class="video-wrapper mb-8"><video autoplay loop muted playsinline><source src="https://cdn-raw.modrinth.com/blog/streamlined-version-creation/vid2.mp4" type="video/mp4"></video></div><h2>Game version selector</h2><p>Game versions now have their own dedicated step. This was a major pain point for projects that support a wide range of game versions. You can search for versions or toggle between releases and snapshots. Select individual versions with a click, or use shift-click to select a range.</p><div class="video-wrapper mb-8"><video autoplay loop muted playsinline><source src="https://cdn-raw.modrinth.com/blog/streamlined-version-creation/vid3.mp4" type="video/mp4"></video></div><h2>Environment selector</h2><p>Project environments were released earlier this year, and we heard feedback that some projects need them configured at the version level. We’ve moved environments out of project settings and into versions. For the vast majority of projects environments rarely change, so we automatically carry them over from a previous version that uses the same loader. You can always edit this if needed.</p><p><img src="/news/article/streamlined-version-creation/environments.webp" alt="Edit environment screen, showing a bunch of options to select such as client-side only, server-side only, and more."></p><h2>Dependency selector</h2><p>Dependencies were another pain point, so we’ve added the ability to search projects and versions directly, no more copying IDs. We also suggest dependencies from the other versions you’ve uploaded with the same loader, making them easy to add with a single click.</p><div class="video-wrapper mb-8"><video autoplay loop muted playsinline><source src="https://cdn-raw.modrinth.com/blog/streamlined-version-creation/vid4.mp4" type="video/mp4"></video></div><h2>Misc</h2><ul><li>Gallery management has now also been moved into Project Settings</li><li>The project publishing checklist now lives in Project Settings</li></ul><hr><p>Thank you all for your continued support. We hope you have a great holiday and get some well-earned time with your families! 🎅</p>]]> + <![CDATA[<p>Hey everyone! As part of our ongoing work to improve the creator side of the platform, we’re shipping a new project version creation and editing today. This part of the product was showing its age, so we’ve overhauled it to set us up for the new project types we plan to ship in the new year!</p><h2>TL;DR</h2><ul><li>Multi-file uploads with primary file detection and new supplementary file types</li><li>Automatic detection of version number, subtitle, loaders, game versions, and environment bundled into a version summary</li><li>A new loader selector that groups loaders by project type</li><li>A new game version selector with search and selecting version ranges</li><li>Project environments moved to be on a per-version basis</li><li>A new dependency selector with search and smart suggestions</li><li>Project gallery, versions, and publishing checklist moved into project settings</li></ul><h2>File uploading</h2><p>For starters, we’ve been centralizing all project editing into Project Settings to make the experience clearer and more approachable for new creators. Editing project versions now happens directly within Project Settings and projects look slightly different if you’re the creator.</p><p><img src="/news/article/streamlined-version-creation/edit-button.webp" alt="Project page header showing the primary action as &quot;Edit project&quot; for the creator"></p><p>You can create a new version by drag and dropping files into the versions table or creating a new version and uploading them. Multiple files can be uploaded at once.</p><p>The primary file is explicitly listed at the top and separate from any supplementary files. From there, you can add additional supplementary files and assign their types. Newly supported types include sources jar, dev jar, javadoc jar, and signature file.</p><div class="video-wrapper mb-8"><video autoplay loop muted playsinline><source src="https://cdn.modrinth.com/blog/streamlined-version-creation/vid1.mp4" type="video/mp4"></video></div><h2>Version summary</h2><p>Once you’ve uploaded your files, you’re taken to a summary page where we automatically detect the version number, subtitle, loaders, game versions, and environments based on the primary file and previous project versions.</p><p>Any field can be individually edited by clicking the edit button in the top right. For cases where we’re unable to detect something, that field simply won’t appear in the summary and will instead show up as an additional step in the modal flow.</p><p><img src="/news/article/streamlined-version-creation/details.webp" alt="Add details stage of the upload modal, where the user selects version type, number, subtitle, and can edit loaders, game versions, and environment metadata."></p><h2>Loader selector</h2><p>We’ve added a refreshed loader selection screen that groups loaders by project type. You can click any loader tag to add it.</p><div class="video-wrapper mb-8"><video autoplay loop muted playsinline><source src="https://cdn.modrinth.com/blog/streamlined-version-creation/vid2.mp4" type="video/mp4"></video></div><h2>Game version selector</h2><p>Game versions now have their own dedicated step. This was a major pain point for projects that support a wide range of game versions. You can search for versions or toggle between releases and snapshots. Select individual versions with a click, or use shift-click to select a range.</p><div class="video-wrapper mb-8"><video autoplay loop muted playsinline><source src="https://cdn.modrinth.com/blog/streamlined-version-creation/vid3.mp4" type="video/mp4"></video></div><h2>Environment selector</h2><p>Project environments were released earlier this year, and we heard feedback that some projects need them configured at the version level. We’ve moved environments out of project settings and into versions. For the vast majority of projects environments rarely change, so we automatically carry them over from a previous version that uses the same loader. You can always edit this if needed.</p><p><img src="/news/article/streamlined-version-creation/environments.webp" alt="Edit environment screen, showing a bunch of options to select such as client-side only, server-side only, and more."></p><h2>Dependency selector</h2><p>Dependencies were another pain point, so we’ve added the ability to search projects and versions directly, no more copying IDs. We also suggest dependencies from the other versions you’ve uploaded with the same loader, making them easy to add with a single click.</p><div class="video-wrapper mb-8"><video autoplay loop muted playsinline><source src="https://cdn.modrinth.com/blog/streamlined-version-creation/vid4.mp4" type="video/mp4"></video></div><h2>Misc</h2><ul><li>Gallery management has now also been moved into Project Settings</li><li>The project publishing checklist now lives in Project Settings</li></ul><hr><p>Thank you all for your continued support. We hope you have a great holiday and get some well-earned time with your families! 🎅</p>]]> <![CDATA[More Ways to Withdraw]]> diff --git a/packages/api-client/src/modules/index.ts b/packages/api-client/src/modules/index.ts index 6740b7a674..f4ec10a874 100644 --- a/packages/api-client/src/modules/index.ts +++ b/packages/api-client/src/modules/index.ts @@ -44,6 +44,7 @@ import { LabrinthOAuthInternalModule } from './labrinth/oauth/internal' import { LabrinthOrganizationsV3Module } from './labrinth/organizations/v3' import { LabrinthPatsV2Module } from './labrinth/pats/v2' import { LabrinthPayoutV3Module } from './labrinth/payout/v3' +import { LabrinthPayoutsInternalModule } from './labrinth/payouts/internal' import { LabrinthPayoutsV3Module } from './labrinth/payouts/v3' import { LabrinthProjectsV2Module } from './labrinth/projects/v2' import { LabrinthProjectsV3Module } from './labrinth/projects/v3' @@ -126,6 +127,7 @@ export const MODULE_REGISTRY = { labrinth_pats_v2: LabrinthPatsV2Module, labrinth_limits_v3: LabrinthLimitsV3Module, labrinth_payout_v3: LabrinthPayoutV3Module, + labrinth_payouts_internal: LabrinthPayoutsInternalModule, labrinth_payouts_v3: LabrinthPayoutsV3Module, labrinth_projects_v2: LabrinthProjectsV2Module, labrinth_projects_v3: LabrinthProjectsV3Module, diff --git a/packages/api-client/src/modules/labrinth/index.ts b/packages/api-client/src/modules/labrinth/index.ts index ec38853f06..b5f6224326 100644 --- a/packages/api-client/src/modules/labrinth/index.ts +++ b/packages/api-client/src/modules/labrinth/index.ts @@ -18,6 +18,7 @@ export * from './oauth/internal' export * from './organizations/v3' export * from './pats/v2' export * from './payout/v3' +export * from './payouts/internal' export * from './payouts/v3' export * from './projects/v2' export * from './projects/v3' diff --git a/packages/api-client/src/modules/labrinth/payouts/internal.ts b/packages/api-client/src/modules/labrinth/payouts/internal.ts new file mode 100644 index 0000000000..9af524f59f --- /dev/null +++ b/packages/api-client/src/modules/labrinth/payouts/internal.ts @@ -0,0 +1,344 @@ +import { AbstractModule } from '../../../core/abstract-module' +import type { Labrinth } from '../types' + +const mockHistory: Labrinth.Payouts.Internal.HistoryItem[] = [ + { + payouts_date: '2026-06', + days: createMockRevenueDays('2026-06', 24_150), + status: 'open', + fees_deducted_usd: 1_440, + variance_adjustment_usd: -2_650, + net_estimated_revenue_usd: 20_060, + creator_net_estimated_revenue_usd: 15_045, + modrinth_net_estimated_revenue_usd: 5_015, + started_at: null, + started_by: null, + detailed_external_adjustments: null, + }, + { + payouts_date: '2026-05', + days: createMockRevenueDays('2026-05', 48_200), + status: 'pending', + fees_deducted_usd: 1_440, + variance_adjustment_usd: -2_650, + net_estimated_revenue_usd: 44_110, + creator_net_estimated_revenue_usd: 33_083, + modrinth_net_estimated_revenue_usd: 11_028, + started_at: null, + started_by: null, + detailed_external_adjustments: null, + }, + { + payouts_date: '2026-04', + days: createMockRevenueDays('2026-04', 45_500), + status: 'pending', + fees_deducted_usd: 1_312, + variance_adjustment_usd: -2_500, + net_estimated_revenue_usd: 41_688, + creator_net_estimated_revenue_usd: 31_266, + modrinth_net_estimated_revenue_usd: 10_422, + started_at: null, + started_by: null, + detailed_external_adjustments: null, + }, + { + payouts_date: '2026-03', + days: createMockRevenueDays('2026-03', 42_000), + status: 'review', + fees_deducted_usd: 1_200, + variance_adjustment_usd: -2_100, + net_estimated_revenue_usd: 38_700, + creator_net_estimated_revenue_usd: 29_025, + modrinth_net_estimated_revenue_usd: 9_675, + started_at: null, + started_by: null, + detailed_external_adjustments: null, + }, + { + payouts_date: '2026-02', + days: createMockRevenueDays('2026-02', 51_000), + status: 'paid', + fees_deducted_usd: 1_520, + variance_adjustment_usd: -2_800, + net_estimated_revenue_usd: 46_680, + creator_net_estimated_revenue_usd: 35_010, + modrinth_net_estimated_revenue_usd: 11_670, + actual_revenue_usd: 48_800, + total_external_adjustment_usd: 0, + net_actual_revenue_usd: 48_800, + creator_net_actual_revenue_usd: 36_600, + modrinth_net_actual_revenue_usd: 12_200, + started_at: '2026-05-16T21:10:00.000Z', + started_by: 'mock-admin-user', + detailed_external_adjustments: [], + }, + { + payouts_date: '2026-01', + days: createMockRevenueDays('2026-01', 49_500), + status: 'paid', + fees_deducted_usd: 1_472, + variance_adjustment_usd: -2_700, + net_estimated_revenue_usd: 45_328, + creator_net_estimated_revenue_usd: 33_996, + modrinth_net_estimated_revenue_usd: 11_332, + actual_revenue_usd: 47_500, + total_external_adjustment_usd: 0, + net_actual_revenue_usd: 47_500, + creator_net_actual_revenue_usd: 35_625, + modrinth_net_actual_revenue_usd: 11_875, + started_at: '2026-04-14T19:45:00.000Z', + started_by: 'mock-admin-user', + detailed_external_adjustments: [], + }, + { + payouts_date: '2025-12', + days: createMockRevenueDays('2025-12', 46_000), + status: 'paid', + fees_deducted_usd: 1_360, + variance_adjustment_usd: -2_500, + net_estimated_revenue_usd: 42_140, + creator_net_estimated_revenue_usd: 31_605, + modrinth_net_estimated_revenue_usd: 10_535, + actual_revenue_usd: 44_000, + total_external_adjustment_usd: 0, + net_actual_revenue_usd: 44_000, + creator_net_actual_revenue_usd: 33_000, + modrinth_net_actual_revenue_usd: 11_000, + started_at: '2026-03-15T20:25:00.000Z', + started_by: 'mock-admin-user', + detailed_external_adjustments: [], + }, + { + payouts_date: '2025-11', + days: createMockRevenueDays('2025-11', 44_000), + status: 'paid', + fees_deducted_usd: 1_280, + variance_adjustment_usd: -2_400, + net_estimated_revenue_usd: 40_320, + creator_net_estimated_revenue_usd: 30_240, + modrinth_net_estimated_revenue_usd: 10_080, + actual_revenue_usd: 42_000, + total_external_adjustment_usd: 0, + net_actual_revenue_usd: 42_000, + creator_net_actual_revenue_usd: 31_500, + modrinth_net_actual_revenue_usd: 10_500, + started_at: '2026-02-14T18:20:00.000Z', + started_by: 'mock-admin-user', + detailed_external_adjustments: [], + }, + { + payouts_date: '2025-10', + days: createMockRevenueDays('2025-10', 43_500), + status: 'paid', + fees_deducted_usd: 1_264, + variance_adjustment_usd: -2_350, + net_estimated_revenue_usd: 39_886, + creator_net_estimated_revenue_usd: 29_915, + modrinth_net_estimated_revenue_usd: 9_972, + actual_revenue_usd: 39_800, + total_external_adjustment_usd: 0, + net_actual_revenue_usd: 39_800, + creator_net_actual_revenue_usd: 29_850, + modrinth_net_actual_revenue_usd: 9_950, + started_at: '2026-01-15T17:30:00.000Z', + started_by: 'mock-admin-user', + detailed_external_adjustments: [], + }, + { + payouts_date: '2025-09', + days: createMockRevenueDays('2025-09', 41_000), + status: 'paid', + fees_deducted_usd: 1_200, + variance_adjustment_usd: -2_200, + net_estimated_revenue_usd: 37_600, + creator_net_estimated_revenue_usd: 28_200, + modrinth_net_estimated_revenue_usd: 9_400, + actual_revenue_usd: 39_200, + total_external_adjustment_usd: 0, + net_actual_revenue_usd: 39_200, + creator_net_actual_revenue_usd: 29_400, + modrinth_net_actual_revenue_usd: 9_800, + started_at: '2025-12-14T17:30:00.000Z', + started_by: 'mock-admin-user', + detailed_external_adjustments: [], + }, + { + payouts_date: '2025-08', + days: createMockRevenueDays('2025-08', 39_500), + status: 'paid', + fees_deducted_usd: 1_152, + variance_adjustment_usd: -2_100, + net_estimated_revenue_usd: 36_248, + creator_net_estimated_revenue_usd: 27_186, + modrinth_net_estimated_revenue_usd: 9_062, + actual_revenue_usd: 37_800, + total_external_adjustment_usd: 0, + net_actual_revenue_usd: 37_800, + creator_net_actual_revenue_usd: 28_350, + modrinth_net_actual_revenue_usd: 9_450, + started_at: '2025-11-14T17:30:00.000Z', + started_by: 'mock-admin-user', + detailed_external_adjustments: [], + }, + { + payouts_date: '2025-07', + days: createMockRevenueDays('2025-07', 38_000), + status: 'paid', + fees_deducted_usd: 1_120, + variance_adjustment_usd: -2_000, + net_estimated_revenue_usd: 34_880, + creator_net_estimated_revenue_usd: 26_160, + modrinth_net_estimated_revenue_usd: 8_720, + actual_revenue_usd: 36_200, + total_external_adjustment_usd: 0, + net_actual_revenue_usd: 36_200, + creator_net_actual_revenue_usd: 27_150, + modrinth_net_actual_revenue_usd: 9_050, + started_at: '2025-10-15T17:30:00.000Z', + started_by: 'mock-admin-user', + detailed_external_adjustments: [], + }, +] + +let mockDistribution: Labrinth.Payouts.Internal.DistributionRun | null = null + +function createMockRevenueDays( + payoutsDate: Labrinth.Payouts.Internal.YearMonth, + totalRevenue: number, +): Labrinth.Payouts.Internal.RevenueDay[] { + const daysInMonth = getMockRevenueDayCount(payoutsDate) + const weights = Array.from({ length: daysInMonth }, (_, index) => { + const weekdayLift = index % 7 === 4 || index % 7 === 5 ? 0.16 : 0 + return 1 + ((index * 7) % 11) / 20 + weekdayLift + }) + const totalWeight = weights.reduce((total, weight) => total + weight, 0) + let allocatedRevenue = 0 + + return weights.map((weight, index) => { + const estimatedRevenue = + index === weights.length - 1 + ? totalRevenue - allocatedRevenue + : Math.round((totalRevenue * weight) / totalWeight) + allocatedRevenue += estimatedRevenue + + return { estimated_revenue_usd: estimatedRevenue } + }) +} + +function getMockRevenueDayCount(payoutsDate: Labrinth.Payouts.Internal.YearMonth): number { + const [year, month] = payoutsDate.split('-').map(Number) + const today = new Date() + + if (today.getFullYear() === year && today.getMonth() === month - 1) { + return today.getDate() + } + + return getDaysInMonth(payoutsDate) +} + +function getDaysInMonth(payoutsDate: Labrinth.Payouts.Internal.YearMonth): number { + const [year, month] = payoutsDate.split('-').map(Number) + return new Date(year, month, 0).getDate() +} + +export class LabrinthPayoutsInternalModule extends AbstractModule { + public getModuleID(): string { + return 'labrinth_payouts_internal' + } + + /** + * Get creator payout history. + * GET /_internal/payouts/history + */ + public async getHistory(): Promise { + return getMockHistory() + + // return this.client.request('/payouts/history', { + // api: 'labrinth', + // version: 'internal', + // method: 'GET', + // }) + } + + /** + * Get the active payout distribution run. + * GET /_internal/payouts/distribution + */ + public async getDistribution(): Promise { + return mockDistribution + + // return this.client.request( + // '/payouts/distribution', + // { + // api: 'labrinth', + // version: 'internal', + // method: 'GET', + // }, + // ) + } + + /** + * Start a payout distribution run. + * POST /_internal/payouts/distribution/start + */ + public async startDistribution( + data: Labrinth.Payouts.Internal.StartDistributionRequest, + ): Promise { + const startedAt = new Date() + mockDistribution = { + payouts_date: data.payouts_date, + amount_received: data.amount_received, + adjustments: data.adjustments, + started_at: startedAt.toISOString(), + started_by: 'mock-admin-user', + distributes_at: new Date(startedAt.getTime() + 2 * 60 * 1000).toISOString(), + } + + return mockDistribution + + // return this.client.request( + // '/payouts/distribution/start', + // { + // api: 'labrinth', + // version: 'internal', + // method: 'POST', + // body: data, + // }, + // ) + } + + /** + * Cancel the active payout distribution run. + * POST /_internal/payouts/distribution/cancel + */ + public async cancelDistribution(): Promise { + mockDistribution = null + + // return this.client.request('/payouts/distribution/cancel', { + // api: 'labrinth', + // version: 'internal', + // method: 'POST', + // }) + } +} + +function getMockHistory(): Labrinth.Payouts.Internal.HistoryItem[] { + if (!mockDistribution) { + return mockHistory + } + + const activeDistribution = mockDistribution + return mockHistory.map((payout) => + payout.payouts_date === activeDistribution.payouts_date + ? { + ...payout, + started_at: activeDistribution.started_at, + started_by: activeDistribution.started_by, + detailed_external_adjustments: activeDistribution.adjustments.map((adjustment) => ({ + description: adjustment.description, + amount_usd: adjustment.amount, + })), + } + : payout, + ) +} diff --git a/packages/api-client/src/modules/labrinth/types.ts b/packages/api-client/src/modules/labrinth/types.ts index 354cf4af33..5ddfda5865 100644 --- a/packages/api-client/src/modules/labrinth/types.ts +++ b/packages/api-client/src/modules/labrinth/types.ts @@ -2270,6 +2270,61 @@ export namespace Labrinth { } export namespace Payouts { + export namespace Internal { + export type YearMonth = string + + export type PayoutStatus = 'open' | 'pending' | 'review' | 'paid' + + export type RevenueDay = { + estimated_revenue_usd: number | null + } + + export type DetailedExternalAdjustment = { + description: string + amount_usd: number + } + + export type HistoryItem = { + payouts_date: YearMonth + days: RevenueDay[] + status: PayoutStatus + fees_deducted_usd: number + variance_adjustment_usd: number + net_estimated_revenue_usd: number + creator_net_estimated_revenue_usd: number + modrinth_net_estimated_revenue_usd: number + actual_revenue_usd?: number + total_external_adjustment_usd?: number + net_actual_revenue_usd?: number + creator_net_actual_revenue_usd?: number + modrinth_net_actual_revenue_usd?: number + started_at: string | null + started_by: string | null + detailed_external_adjustments: DetailedExternalAdjustment[] | null + } + + export type DistributionAdjustment = { + description: string + amount: number + } + + export type StartDistributionRequest = { + payouts_date: YearMonth + totp_code: string + amount_received: number + adjustments: DistributionAdjustment[] + } + + export type DistributionRun = { + payouts_date: YearMonth + amount_received: number + adjustments: DistributionAdjustment[] + started_at: string + started_by: string + distributes_at: string + } + } + export namespace v3 { export type RevenueData = { time: number diff --git a/packages/blog/compiled/streamlined_version_creation.content.ts b/packages/blog/compiled/streamlined_version_creation.content.ts index 09abfe58d4..36b7e4c77c 100644 --- a/packages/blog/compiled/streamlined_version_creation.content.ts +++ b/packages/blog/compiled/streamlined_version_creation.content.ts @@ -1,2 +1,2 @@ // AUTO-GENERATED FILE - DO NOT EDIT -export const html = `

Hey everyone! As part of our ongoing work to improve the creator side of the platform, we’re shipping a new project version creation and editing today. This part of the product was showing its age, so we’ve overhauled it to set us up for the new project types we plan to ship in the new year!

TL;DR

  • Multi-file uploads with primary file detection and new supplementary file types
  • Automatic detection of version number, subtitle, loaders, game versions, and environment bundled into a version summary
  • A new loader selector that groups loaders by project type
  • A new game version selector with search and selecting version ranges
  • Project environments moved to be on a per-version basis
  • A new dependency selector with search and smart suggestions
  • Project gallery, versions, and publishing checklist moved into project settings

File uploading

For starters, we’ve been centralizing all project editing into Project Settings to make the experience clearer and more approachable for new creators. Editing project versions now happens directly within Project Settings and projects look slightly different if you’re the creator.

Project page header showing the primary action as "Edit project" for the creator

You can create a new version by drag and dropping files into the versions table or creating a new version and uploading them. Multiple files can be uploaded at once.

The primary file is explicitly listed at the top and separate from any supplementary files. From there, you can add additional supplementary files and assign their types. Newly supported types include sources jar, dev jar, javadoc jar, and signature file.

Version summary

Once you’ve uploaded your files, you’re taken to a summary page where we automatically detect the version number, subtitle, loaders, game versions, and environments based on the primary file and previous project versions.

Any field can be individually edited by clicking the edit button in the top right. For cases where we’re unable to detect something, that field simply won’t appear in the summary and will instead show up as an additional step in the modal flow.

Add details stage of the upload modal, where the user selects version type, number, subtitle, and can edit loaders, game versions, and environment metadata.

Loader selector

We’ve added a refreshed loader selection screen that groups loaders by project type. You can click any loader tag to add it.

Game version selector

Game versions now have their own dedicated step. This was a major pain point for projects that support a wide range of game versions. You can search for versions or toggle between releases and snapshots. Select individual versions with a click, or use shift-click to select a range.

Environment selector

Project environments were released earlier this year, and we heard feedback that some projects need them configured at the version level. We’ve moved environments out of project settings and into versions. For the vast majority of projects environments rarely change, so we automatically carry them over from a previous version that uses the same loader. You can always edit this if needed.

Edit environment screen, showing a bunch of options to select such as client-side only, server-side only, and more.

Dependency selector

Dependencies were another pain point, so we’ve added the ability to search projects and versions directly, no more copying IDs. We also suggest dependencies from the other versions you’ve uploaded with the same loader, making them easy to add with a single click.

Misc

  • Gallery management has now also been moved into Project Settings
  • The project publishing checklist now lives in Project Settings

Thank you all for your continued support. We hope you have a great holiday and get some well-earned time with your families! 🎅

`; +export const html = `

Hey everyone! As part of our ongoing work to improve the creator side of the platform, we’re shipping a new project version creation and editing today. This part of the product was showing its age, so we’ve overhauled it to set us up for the new project types we plan to ship in the new year!

TL;DR

  • Multi-file uploads with primary file detection and new supplementary file types
  • Automatic detection of version number, subtitle, loaders, game versions, and environment bundled into a version summary
  • A new loader selector that groups loaders by project type
  • A new game version selector with search and selecting version ranges
  • Project environments moved to be on a per-version basis
  • A new dependency selector with search and smart suggestions
  • Project gallery, versions, and publishing checklist moved into project settings

File uploading

For starters, we’ve been centralizing all project editing into Project Settings to make the experience clearer and more approachable for new creators. Editing project versions now happens directly within Project Settings and projects look slightly different if you’re the creator.

Project page header showing the primary action as "Edit project" for the creator

You can create a new version by drag and dropping files into the versions table or creating a new version and uploading them. Multiple files can be uploaded at once.

The primary file is explicitly listed at the top and separate from any supplementary files. From there, you can add additional supplementary files and assign their types. Newly supported types include sources jar, dev jar, javadoc jar, and signature file.

Version summary

Once you’ve uploaded your files, you’re taken to a summary page where we automatically detect the version number, subtitle, loaders, game versions, and environments based on the primary file and previous project versions.

Any field can be individually edited by clicking the edit button in the top right. For cases where we’re unable to detect something, that field simply won’t appear in the summary and will instead show up as an additional step in the modal flow.

Add details stage of the upload modal, where the user selects version type, number, subtitle, and can edit loaders, game versions, and environment metadata.

Loader selector

We’ve added a refreshed loader selection screen that groups loaders by project type. You can click any loader tag to add it.

Game version selector

Game versions now have their own dedicated step. This was a major pain point for projects that support a wide range of game versions. You can search for versions or toggle between releases and snapshots. Select individual versions with a click, or use shift-click to select a range.

Environment selector

Project environments were released earlier this year, and we heard feedback that some projects need them configured at the version level. We’ve moved environments out of project settings and into versions. For the vast majority of projects environments rarely change, so we automatically carry them over from a previous version that uses the same loader. You can always edit this if needed.

Edit environment screen, showing a bunch of options to select such as client-side only, server-side only, and more.

Dependency selector

Dependencies were another pain point, so we’ve added the ability to search projects and versions directly, no more copying IDs. We also suggest dependencies from the other versions you’ve uploaded with the same loader, making them easy to add with a single click.

Misc

  • Gallery management has now also been moved into Project Settings
  • The project publishing checklist now lives in Project Settings

Thank you all for your continued support. We hope you have a great holiday and get some well-earned time with your families! 🎅

`; diff --git a/packages/ui/src/components/base/Table.vue b/packages/ui/src/components/base/Table.vue index c4c9f44a46..0399c4fd16 100644 --- a/packages/ui/src/components/base/Table.vue +++ b/packages/ui/src/components/base/Table.vue @@ -90,6 +90,7 @@ string) /** * Sets a minimum width for the table content, allowing horizontal overflow below that width. */ @@ -424,6 +435,14 @@ function handleRowClick(row: T, rowIndex: number, event: MouseEvent) { emit('rowClick', row, rowIndex, event) } +function getBodyCellClass(row: T, rowIndex: number): string { + if (typeof props.bodyCellClass === 'function') { + return props.bodyCellClass(row, rowIndex) + } + + return props.bodyCellClass ?? 'h-14' +} + function isSelected(row: T): boolean { return selectedIdSet.value.has(getSelectionId(row)) }