feat(frontend): Improve revenue information (#3250)

* Improve revenue information

* Improve NET 60 period info + show next period if current period is over.

* invert period check

* %

* Finalize changes

* Cleanup

* Remove .idea

* Discard changes to .idea/discord.xml

* Discard changes to .idea/code.iml

* Discard changes to .idea/.gitignore

* Discard changes to .idea/libraries/KotlinJavaRuntime.xml

* Discard changes to .idea/vcs.xml

* Discard changes to .idea/modules.xml

* Discard changes to .idea/.gitignore

* fix lint issues

* table fix, lint fix and media sizing fix

* fix responsiveness

* Remove comment

* utc comment

* fix lint
This commit is contained in:
Calum H.
2025-02-21 01:52:10 +00:00
committed by GitHub
parent 067f471766
commit c77f3395b2
4 changed files with 269 additions and 69 deletions

View File

@@ -2,38 +2,87 @@
<div> <div>
<section class="universal-card"> <section class="universal-card">
<h2 class="text-2xl">Revenue</h2> <h2 class="text-2xl">Revenue</h2>
<div v-if="userBalance.available >= minWithdraw"> <div class="grid-display">
<p> <div class="grid-display__item">
You have <div class="label">Available now</div>
<strong>{{ $formatMoney(userBalance.available) }}</strong> <div class="value">
available to withdraw. <strong>{{ $formatMoney(userBalance.pending) }}</strong> of your {{ $formatMoney(userBalance.available) }}
balance is <nuxt-link class="text-link" to="/legal/cmp-info#pending">pending</nuxt-link>. </div>
</p> </div>
<div class="grid-display__item">
<div class="label">
Total pending
<nuxt-link
v-tooltip="`Click to read about how Modrinth handles your revenue.`"
class="align-middle text-link"
to="/legal/cmp-info#pending"
>
<UnknownIcon />
</nuxt-link>
</div>
<div class="value">
{{ $formatMoney(userBalance.pending) }}
</div>
</div>
<div class="grid-display__item available-soon">
<h3 class="label">
Available soon
<nuxt-link
v-tooltip="`Click to read about how Modrinth handles your revenue.`"
class="align-middle text-link"
to="/legal/cmp-info#pending"
>
<UnknownIcon />
</nuxt-link>
</h3>
<ul class="available-soon-list">
<li v-for="date in availableSoonDateKeys" :key="date" class="available-soon-item">
<span class="amount">
{{ $formatMoney(availableSoonDates[date]) }}
<small
v-if="availableSoonDateKeys.indexOf(date) === availableSoonDateKeys.length - 1"
></small
>
</span>
<span class="date">
{{ formatDate(dayjs(date)) }}
</span>
</li>
</ul>
</div>
</div> </div>
<p v-else>
You have made
<strong>{{ $formatMoney(userBalance.available) }}</strong
>, which is under the minimum of ${{ minWithdraw }} to withdraw.
<strong>{{ $formatMoney(userBalance.pending) }}</strong> of your balance is
<nuxt-link class="text-link" to="/legal/cmp-info#pending">pending</nuxt-link>.
</p>
<div class="input-group mt-4"> <div class="input-group mt-4">
<nuxt-link <span :class="{ 'disabled-cursor-wrapper': userBalance.available < minWithdraw }">
v-if="userBalance.available >= minWithdraw" <nuxt-link
class="iconified-button brand-button" :aria-disabled="userBalance.available < minWithdraw ? 'true' : 'false'"
to="/dashboard/revenue/withdraw" :class="{ 'disabled-link': userBalance.available < minWithdraw }"
> :disabled="userBalance.available < minWithdraw ? 'true' : 'false'"
<TransferIcon /> Withdraw :tabindex="userBalance.available < minWithdraw ? -1 : undefined"
</nuxt-link> class="iconified-button brand-button"
to="/dashboard/revenue/withdraw"
>
<TransferIcon /> Withdraw
</nuxt-link>
</span>
<NuxtLink class="iconified-button" to="/dashboard/revenue/transfers"> <NuxtLink class="iconified-button" to="/dashboard/revenue/transfers">
<HistoryIcon /> View transfer history <HistoryIcon />
View transfer history
</NuxtLink> </NuxtLink>
</div> </div>
<p> <p>
By uploading projects to Modrinth and withdrawing money from your account, you agree to the <small>
<nuxt-link to="/legal/cmp" class="text-link">Rewards Program Terms</nuxt-link>. For more By uploading projects to Modrinth and withdrawing money from your account, you agree to
information on how the rewards system works, see our information page the
<nuxt-link to="/legal/cmp-info" class="text-link">here</nuxt-link>. <nuxt-link class="text-link" to="/legal/cmp">Rewards Program Terms</nuxt-link>. For more
information on how the rewards system works, see our information page
<nuxt-link class="text-link" to="/legal/cmp-info">here</nuxt-link>.
</small>
</p>
<p>
<small>
Ongoing revenue period, subject to change. The finalized amount will be available to
view on the last day of the current month.
</small>
</p> </p>
</section> </section>
<section class="universal-card"> <section class="universal-card">
@@ -46,12 +95,13 @@
{{ auth.user.payout_data.paypal_address }} {{ auth.user.payout_data.paypal_address }}
</p> </p>
<button class="btn mt-4" @click="removeAuthProvider('paypal')"> <button class="btn mt-4" @click="removeAuthProvider('paypal')">
<XIcon /> Disconnect account <XIcon />
Disconnect account
</button> </button>
</template> </template>
<template v-else> <template v-else>
<p>Connect your PayPal account to enable withdrawing to your PayPal balance.</p> <p>Connect your PayPal account to enable withdrawing to your PayPal balance.</p>
<a class="btn mt-4" :href="`${getAuthUrl('paypal')}&token=${auth.token}`"> <a :href="`${getAuthUrl('paypal')}&token=${auth.token}`" class="btn mt-4">
<PayPalIcon /> <PayPalIcon />
Sign in with PayPal Sign in with PayPal
</a> </a>
@@ -60,7 +110,8 @@
<p> <p>
Tremendous payments are sent to your Modrinth email. To change/set your Modrinth email, Tremendous payments are sent to your Modrinth email. To change/set your Modrinth email,
visit visit
<nuxt-link to="/settings/account" class="text-link">here</nuxt-link>. <nuxt-link class="text-link" to="/settings/account">here</nuxt-link>
.
</p> </p>
<h3>Venmo</h3> <h3>Venmo</h3>
<p>Enter your Venmo username below to enable withdrawing to your Venmo balance.</p> <p>Enter your Venmo username below to enable withdrawing to your Venmo balance.</p>
@@ -68,18 +119,31 @@
<input <input
id="venmo" id="venmo"
v-model="auth.user.payout_data.venmo_handle" v-model="auth.user.payout_data.venmo_handle"
autocomplete="off"
class="mt-4" class="mt-4"
type="search"
name="search" name="search"
placeholder="@example" placeholder="@example"
autocomplete="off" type="search"
/> />
<button class="btn btn-secondary" @click="updateVenmo"><SaveIcon /> Save information</button> <button class="btn btn-secondary" @click="updateVenmo">
<SaveIcon />
Save information
</button>
</section> </section>
</div> </div>
</template> </template>
<script setup> <script setup>
import { TransferIcon, HistoryIcon, PayPalIcon, SaveIcon, XIcon } from "@modrinth/assets"; import {
HistoryIcon,
PayPalIcon,
SaveIcon,
TransferIcon,
UnknownIcon,
XIcon,
} from "@modrinth/assets";
import { formatDate } from "@modrinth/utils";
import dayjs from "dayjs";
import { computed } from "vue";
const auth = await useAuth(); const auth = await useAuth();
const minWithdraw = ref(0.01); const minWithdraw = ref(0.01);
@@ -88,6 +152,33 @@ const { data: userBalance } = await useAsyncData(`payout/balance`, () =>
useBaseFetch(`payout/balance`, { apiVersion: 3 }), useBaseFetch(`payout/balance`, { apiVersion: 3 }),
); );
const deadlineEnding = computed(() => {
let deadline = dayjs().subtract(2, "month").endOf("month").add(60, "days");
if (deadline.isBefore(dayjs().startOf("day"))) {
deadline = dayjs().subtract(1, "month").endOf("month").add(60, "days");
}
return deadline;
});
const availableSoonDates = computed(() => {
// Get the next 3 dates from userBalance.dates that are from now to the deadline + 4 months to make sure we get all the pending ones.
const dates = Object.keys(userBalance.value.dates)
.filter((date) => {
const dateObj = dayjs(date);
return (
dateObj.isAfter(dayjs()) && dateObj.isBefore(dayjs(deadlineEnding.value).add(4, "month"))
);
})
.sort((a, b) => dayjs(a).diff(dayjs(b)));
return dates.reduce((acc, date) => {
acc[date] = userBalance.value.dates[date];
return acc;
}, {});
});
const availableSoonDateKeys = computed(() => Object.keys(availableSoonDates.value));
async function updateVenmo() { async function updateVenmo() {
startLoading(); startLoading();
try { try {
@@ -118,4 +209,57 @@ strong {
color: var(--color-text-dark); color: var(--color-text-dark);
font-weight: 500; font-weight: 500;
} }
.disabled-cursor-wrapper {
cursor: not-allowed;
}
.disabled-link {
pointer-events: none;
}
.grid-display {
grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr));
}
.available-soon {
padding-top: 0;
.label {
margin: 0;
}
&-list {
list-style-type: none;
padding: 0;
margin: 0;
}
&-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.2rem 0 0;
border-bottom: 1px solid var(--color-divider);
.amount {
font-weight: 600;
small {
vertical-align: top;
margin: 0;
padding: 0;
}
}
.date {
color: var(--color-text-secondary);
font-size: 0.9em;
}
&:last-child {
border-bottom: none;
}
}
}
</style> </style>

View File

@@ -82,42 +82,41 @@
<p> <p>
Modrinth receives ad revenue from our ad providers on a NET 60 day basis. Due to this, not all Modrinth receives ad revenue from our ad providers on a NET 60 day basis. Due to this, not all
revenue is immediately available to withdraw. We pay creators as soon as we receive the money revenue is immediately available to withdraw. We pay creators as soon as we receive the money
from our ad providers, which is 60 days after the last day of each month. This table outlines from our ad providers, which is 60 days after the last day of each month.
some example dates of how NET 60 payments are made:
</p> </p>
<p>
To understand when revenue becomes available, you can use this calculator to estimate when
revenue earned on a specific date will be available for withdrawal. Please be advised that all
dates within this calculator are represented at 00:00 UTC.
</p>
<table> <table>
<thead> <tr>
<tr> <th>Timeline</th>
<th>Date</th> <th>Date</th>
<th>Payment available date</th> </tr>
</tr> <tr>
</thead> <td>Revenue earned on</td>
<tbody> <td>
<tr> <input id="revenue-date-picker" v-model="rawSelectedDate" type="date" />
<td>January 1st</td> <noscript
<td>March 31st</td> >(JavaScript must be enabled for the date picker to function, example date: 2024-07-15)
</tr> </noscript>
<tr> </td>
<td>January 15th</td> </tr>
<td>March 31st</td> <tr>
</tr> <td>End of the month</td>
<tr> <td>{{ formatDate(endOfMonthDate) }}</td>
<td>March 3rd</td> </tr>
<td>May 30th</td> <tr>
</tr> <td>NET 60 policy applied</td>
<tr> <td>+ 60 days</td>
<td>June 30th</td> </tr>
<td>August 29th</td> <tr class="final-result">
</tr> <td>Available for withdrawal</td>
<tr> <td>{{ formatDate(withdrawalDate) }}</td>
<td>July 14th</td> </tr>
<td>September 29th</td>
</tr>
<tr>
<td>October 12th</td>
<td>December 30th</td>
</tr>
</tbody>
</table> </table>
<h3>How do I know Modrinth is being transparent about revenue?</h3> <h3>How do I know Modrinth is being transparent about revenue?</h3>
<p> <p>
@@ -127,12 +126,40 @@
revenue distribution system</a revenue distribution system</a
>. We also have an >. We also have an
<a href="https://api.modrinth.com/v3/payout/platform_revenue">API route</a> that allows users <a href="https://api.modrinth.com/v3/payout/platform_revenue">API route</a> that allows users
to query exact daily revenue for the site. to query exact daily revenue for the site - so far, Modrinth has generated
<strong>{{ formatMoney(platformRevenue) }}</strong> in revenue.
</p> </p>
<table>
<thead>
<tr>
<th>Date</th>
<th>Revenue</th>
<th>Creator Revenue (75%)</th>
<th>Modrinth's Cut (25%)</th>
</tr>
</thead>
<tbody>
<tr v-for="item in platformRevenueData" :key="item.time">
<td>{{ formatDate(dayjs.unix(item.time)) }}</td>
<td>{{ formatMoney(item.revenue) }}</td>
<td>{{ formatMoney(item.creator_revenue) }}</td>
<td>{{ formatMoney(item.revenue - item.creator_revenue) }}</td>
</tr>
</tbody>
</table>
<small
>Modrinth's total revenue in the previous 5 days, for the entire dataset, use the
aforementioned
<a href="https://api.modrinth.com/v3/payout/platform_revenue">API route</a>.</small
>
</div> </div>
</template> </template>
<script setup> <script lang="ts" setup>
import dayjs from "dayjs";
import { computed, ref } from "vue";
import { formatDate, formatMoney } from "@modrinth/utils";
const description = const description =
"Information about the Rewards Program of Modrinth, an open source modding platform focused on Minecraft."; "Information about the Rewards Program of Modrinth, an open source modding platform focused on Minecraft.";
@@ -142,4 +169,18 @@ useSeoMeta({
ogTitle: "Rewards Program Information", ogTitle: "Rewards Program Information",
ogDescription: description, ogDescription: description,
}); });
const rawSelectedDate = ref(dayjs().format("YYYY-MM-DD"));
const selectedDate = computed(() => dayjs(rawSelectedDate.value));
const endOfMonthDate = computed(() => selectedDate.value.endOf("month"));
const withdrawalDate = computed(() => endOfMonthDate.value.add(60, "days"));
const { data: transparencyInformation } = await useAsyncData("payout/platform_revenue", () =>
useBaseFetch("payout/platform_revenue", {
apiVersion: 3,
}),
);
const platformRevenue = transparencyInformation.value.all_time;
const platformRevenueData = transparencyInformation.value.data.slice(0, 5);
</script> </script>

View File

@@ -1,7 +1,11 @@
import dayjs from "dayjs"; import dayjs from "dayjs";
import quarterOfYear from "dayjs/plugin/quarterOfYear"; import quarterOfYear from "dayjs/plugin/quarterOfYear";
import advanced from "dayjs/plugin/advancedFormat";
import relativeTime from "dayjs/plugin/relativeTime";
dayjs.extend(quarterOfYear); dayjs.extend(quarterOfYear);
dayjs.extend(advanced);
dayjs.extend(relativeTime);
export default defineNuxtPlugin(() => { export default defineNuxtPlugin(() => {
return { return {

View File

@@ -87,6 +87,17 @@ export const formatNumber = (number, abbreviate = true) => {
return x.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',') return x.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',')
} }
export function formatDate(
date: dayjs.Dayjs,
options: Intl.DateTimeFormatOptions = {
month: 'long',
day: 'numeric',
year: 'numeric',
},
): string {
return date.toDate().toLocaleDateString(undefined, options)
}
export function formatMoney(number, abbreviate = false) { export function formatMoney(number, abbreviate = false) {
const x = Number(number) const x = Number(number)
if (x >= 1000000 && abbreviate) { if (x >= 1000000 && abbreviate) {