New payouts system (#1456)

* initial

* finish withdraw page

* Finish frontend

* Fix UI a bit

* tester fixes
This commit is contained in:
Geometrically 2023-12-05 12:13:27 -07:00 committed by GitHub
parent 1f58aebb2b
commit 2d14e5682d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 691 additions and 670 deletions

3
assets/images/external/tremendous.svg vendored Normal file
View File

@ -0,0 +1,3 @@
<svg viewBox="0 0 21 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M20.8586 1.56939H0V6.06253H7.92626V22.4304H12.9323V6.06253H20.8586V1.56939Z" fill="currentColor"></path>
</svg>

After

Width:  |  Height:  |  Size: 196 B

View File

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" x="0px" y="0px" viewBox="0 0 24 24">
<path fill="currentColor" d="M17,3H7C4.8,3,3,4.8,3,7v10c0,2.2,1.8,4,4,4h10c2.2,0,4-1.8,4-4V7C21,4.8,19.2,3,17,3z M12.88,17.25c-3.5,0-4.13,0-4.13,0 l-1.63-10l3.63-0.5L11.62,14c0,0,2.63-3.75,1.5-6.63c0.76-0.12,3.26-0.62,3.26-0.62S19.25,8.5,12.88,17.25z"></path>
</svg>

After

Width:  |  Height:  |  Size: 346 B

38
assets/images/external/venmo.svg vendored Normal file
View File

@ -0,0 +1,38 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns="http://www.w3.org/2000/svg"
width="194.46875mm"
height="35.71875mm"
viewBox="0 0 194.46875 35.71875"
version="1.1"
>
<g
id="layer1"
transform="translate(-5.7593441,-130.66032)">
<g
style="fill:none"
id="g838"
transform="matrix(0.9244437,0,0,0.89314785,5.7593441,129.92613)">
<path
id="path815"
d="m 34.5771,0.822021 c 1.4203,2.345309 2.0606,4.760989 2.0606,7.812489 0,9.73269 -8.31,22.37619 -15.0545,31.25429 H 6.17825 L 0,2.95296 13.4887,1.67258 16.7552,27.9548 c 3.0522,-4.9714 6.8186,-12.7838 6.8186,-18.11027 0,-2.91551 -0.4995,-4.90135 -1.2803,-6.53647 z"
style="fill:#fff" />
<path
id="path817"
d="m 52.0595,17.0887 c 2.4822,0 8.7312,-1.1353 8.7312,-4.6863 0,-1.7051 -1.2059,-2.55564 -2.627,-2.55564 -2.4861,0 -5.7487,2.98074 -6.1042,7.24194 z m -0.2844,7.0327 c 0,4.3359 2.4114,6.037 5.6083,6.037 3.4813,0 6.8145,-0.8506 11.1469,-3.0519 l -1.6318,11.0787 c -3.0525,1.4911 -7.8097,2.4861 -12.4272,2.4861 -11.7129,0 -15.9049,-7.102 -15.9049,-15.9805 0,-11.5074 6.8189,-23.7262 20.8772,-23.7262 7.7401,0 12.0681,4.33553 12.0681,10.3725 7e-4,9.7324 -12.4929,12.7139 -19.7366,12.7843 z"
style="fill:#fff" />
<path
id="path819"
d="m 110.439,9.34835 c 0,1.42035 -0.215,3.48055 -0.43,4.82695 l -4.047,25.5721 H 92.8275 l 3.6921,-23.4415 c 0.07,-0.6358 0.2852,-1.9158 0.2852,-2.626 0,-1.7052 -1.0655,-2.1306 -2.3465,-2.1306 -1.7015,0 -3.407,0.7805 -4.5428,1.3504 l -4.1877,26.848 H 72.5195 L 78.5537,1.46185 h 11.4318 l 0.1447,3.05588 c 2.697,-1.77549 6.2483,-3.695708 11.2868,-3.695708 6.676,-7.3e-4 9.022,3.409878 9.022,8.526328 z"
style="fill:#fff" />
<path
id="path821"
d="m 149.432,5.15577 c 3.762,-2.69641 7.314,-4.19117 12.211,-4.19117 6.744,0 9.09,3.41061 9.09,8.52707 0,1.42043 -0.215,3.48063 -0.429,4.82703 l -4.043,25.572 h -13.138 l 3.763,-23.9369 c 0.069,-0.6399 0.215,-1.4204 0.215,-1.9155 0,-1.9199 -1.066,-2.3457 -2.347,-2.3457 -1.631,0 -3.262,0.7102 -4.473,1.3504 l -4.187,26.8481 H 132.96 l 3.762,-23.937 c 0.069,-0.6398 0.211,-1.4203 0.211,-1.9154 0,-1.9199 -1.067,-2.3457 -2.343,-2.3457 -1.705,0 -3.407,0.7805 -4.543,1.3504 l -4.191,26.8481 h -13.204 l 6.034,-38.28598 h 11.292 l 0.355,3.19624 c 2.627,-1.91548 6.175,-3.835703 10.932,-3.835703 4.119,-0.001458 6.815,1.774393 8.167,4.189713 z"
style="fill:#fff" />
<path
id="path823"
d="m 196.869,16.3076 c 0,-3.1255 -0.782,-5.2564 -3.123,-5.2564 -5.183,0 -6.248,9.1621 -6.248,13.8491 0,3.5557 0.995,5.7563 3.336,5.7563 4.899,0 6.035,-9.6624 6.035,-14.349 z m -22.719,8.0269 c 0,-12.0737 6.389,-23.371121 21.088,-23.371121 11.076,0 15.125,6.536471 15.125,15.558621 0,11.9336 -6.32,24.292 -21.374,24.292 -11.147,0 -14.839,-7.317 -14.839,-16.4795 z"
style="fill:#fff" />
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.9 KiB

View File

@ -268,7 +268,7 @@
@media screen and (max-width: 750px) {
&:not(&.small) {
flex-direction: column;
align-items: start;
align-items: flex-start;
.stylized-toggle {
flex-basis: 0;
@ -339,7 +339,7 @@
.adjacent-input,
&.adjacent-input &:not(&.small) {
flex-direction: column;
align-items: start;
align-items: flex-start;
}
}
}

View File

@ -1,207 +0,0 @@
<template>
<Modal ref="modal" :header="'Transfer to ' + $formatWallet(wallet)">
<div class="modal-transfer">
<span
>You are initiating a transfer of your revenue from Modrinth's Creator Monetization Program.
How much of your <strong>{{ $formatMoney(balance) }}</strong> balance would you like to
transfer?</span
>
<div class="confirmation-input">
<input
id="confirmation"
v-model="amount"
type="text"
pattern="^\d*(\.\d{0,2})?$"
autocomplete="off"
placeholder="Amount to transfer..."
/>
</div>
<div class="confirm-text">
<Checkbox
v-if="isValidInput() && parseInput() >= minWithdraw && parseInput() <= balance"
v-model="consentedFee"
description="Consent to fee"
>
<template v-if="wallet === 'venmo'">
I acknowledge that $0.25 will be deducted from the amount I receive to cover
{{ $formatWallet(wallet) }} processing fees.
</template>
<template v-else>
I acknowledge that an estimated
{{ $formatMoney(calcProcessingFees()) }} will be deducted from the amount I receive to
cover {{ $formatWallet(wallet) }} processing fees and that any excess will be returned
to my Modrinth balance.
</template>
</Checkbox>
<Checkbox
v-if="isValidInput() && parseInput() >= minWithdraw && parseInput() <= balance"
v-model="consentedAccount"
description="Confirm transfer"
>
I confirm that I an initiating a transfer to the following
{{ $formatWallet(wallet) }} account: {{ account }}
</Checkbox>
<span v-else-if="validInput && parseInput() < minWithdraw" class="invalid">
The amount must be at least {{ $formatMoney(minWithdraw) }}</span
>
<span v-else-if="validInput && parseInput() > balance" class="invalid">
The amount must be no more than {{ $formatMoney(balance) }}</span
>
<span v-else-if="amount.length > 0" class="invalid">
{{ amount }} is not a valid amount</span
>
</div>
<div class="button-group">
<NuxtLink class="iconified-button" to="/settings/monetization">
<SettingsIcon /> Monetization settings
</NuxtLink>
<button class="iconified-button" @click="cancel">
<CrossIcon />
Cancel
</button>
<button
class="iconified-button brand-button"
:disabled="!consentedFee || !consentedAccount"
@click="proceed"
>
<TransferIcon />
Transfer
</button>
</div>
</div>
</Modal>
</template>
<script>
import CrossIcon from '~/assets/images/utils/x.svg'
import TransferIcon from '~/assets/images/utils/transfer.svg'
import SettingsIcon from '~/assets/images/utils/settings.svg'
import Modal from '~/components/ui/Modal.vue'
import Checkbox from '~/components/ui/Checkbox.vue'
export default {
components: {
Checkbox,
CrossIcon,
SettingsIcon,
TransferIcon,
Modal,
},
props: {
wallet: {
type: String,
required: true,
},
accountType: {
type: String,
required: true,
},
account: {
type: String,
required: true,
},
balance: {
type: Number,
required: true,
},
minWithdraw: {
type: Number,
required: true,
},
},
data() {
return {
consentedFee: false,
consentedAccount: false,
amount: '',
validInput: false,
}
},
methods: {
cancel() {
this.amount = ''
this.consentedFee = false
this.consentedAccount = false
this.validInput = false
this.$refs.modal.hide()
},
async proceed() {
startLoading()
try {
const auth = await useAuth()
await useBaseFetch(`user/${auth.value.user.id}/payouts`, {
method: 'POST',
body: {
amount: Number(this.amount.replace('$', '')),
},
})
await useAuth(auth.value.token)
this.$refs.modal.hide()
} catch (err) {
this.$notify({
group: 'main',
title: 'An error occurred',
text: err.data.description,
type: 'error',
})
}
stopLoading()
},
show() {
this.$refs.modal.show()
},
isValidInput() {
const regex = /^\$?(\d*(\.\d{2})?)$/gm
this.validInput = regex.test(this.amount) && this.amount.length > 0
return this.validInput
},
parseInput() {
const regex = /^\$?(\d*(\.\d{2})?)$/gm
const matches = regex.exec(this.amount)
return parseFloat(matches[1])
},
calcProcessingFees() {
if (this.wallet === 'venmo') {
return 0.25
} else {
return Math.max(0.25, Math.min(this.parseInput() * 0.02, 20))
}
},
},
}
</script>
<style scoped lang="scss">
.modal-transfer {
padding: var(--spacing-card-bg);
display: flex;
flex-direction: column;
gap: var(--spacing-card-sm);
.confirmation-input {
input {
width: 14rem;
max-width: 100%;
}
}
strong {
color: var(--color-text-dark);
font-weight: 500;
}
.invalid {
color: var(--color-special-red);
}
.confirm-text {
margin-top: var(--spacing-card-sm);
min-height: 7rem;
display: flex;
flex-direction: column;
gap: var(--spacing-card-sm);
}
}
</style>

View File

@ -103,3 +103,27 @@ export const getAuthUrl = (provider, redirect = '') => {
return `${config.public.apiBaseUrl}auth/init?url=${fullURL}&provider=${provider}`
}
export const removeAuthProvider = async (provider) => {
startLoading()
try {
const auth = await useAuth()
await useBaseFetch('auth/provider', {
method: 'DELETE',
body: {
provider,
},
})
await useAuth(auth.value.token)
} catch (err) {
const data = useNuxtApp()
data.$notify({
group: 'main',
title: 'An error occurred',
text: err.data.description,
type: 'error',
})
}
stopLoading()
}

View File

@ -43,6 +43,7 @@
"floating-vue": "^2.0.0-beta.20",
"fuse.js": "^6.6.2",
"highlight.js": "^11.7.0",
"iso-3166-1": "^2.1.1",
"js-yaml": "^4.1.0",
"jszip": "^3.10.1",
"markdown-it": "^13.0.1",

View File

@ -87,13 +87,6 @@
></span
>
</div>
<div class="grid-display__item">
<div class="label">Total revenue</div>
<div class="value">
{{ $formatMoney(payouts.all_time, true) }}
</div>
<span>{{ $formatMoney(payouts.last_month, true) }} in the last month</span>
</div>
<div class="grid-display__item">
<div class="label">Current balance</div>
<div class="value">
@ -127,13 +120,10 @@ useHead({
const auth = await useAuth()
const [{ data: projects }, { data: payouts }] = await Promise.all([
const [{ data: projects }] = await Promise.all([
useAsyncData(`user/${auth.value.user.id}/projects`, () =>
useBaseFetch(`user/${auth.value.user.id}/projects`)
),
useAsyncData(`user/${auth.value.user.id}/payouts`, () =>
useBaseFetch(`user/${auth.value.user.id}/payouts`)
),
])
const minWithdraw = ref(0.26)

View File

@ -1,132 +1,109 @@
<template>
<div>
<ModalTransfer
v-if="enrolled"
ref="modal_transfer"
:wallet="auth.user.payout_data.payout_wallet"
:account-type="auth.user.payout_data.payout_wallet_type"
:account="auth.user.payout_data.payout_address"
:balance="auth.user.payout_data.balance"
:min-withdraw="minWithdraw"
/>
<section class="universal-card">
<h2>Withdraw</h2>
<h2>Revenue</h2>
<div v-if="auth.user.payout_data.balance >= minWithdraw">
<p>
You have
<strong>{{ $formatMoney(auth.user.payout_data.balance) }}</strong>
available to withdraw.
<span v-if="!enrolled"
>Enroll in the Creator Monetization Program to withdraw your revenue.</span
>
</p>
</div>
<p v-else-if="auth.user.payout_data.balance > 0">
You have made
<strong>{{ $formatMoney(auth.user.payout_data.balance) }}</strong
>, however you have not yet met the minimum of ${{ minWithdraw }} to withdraw.
</p>
<p v-else>
You have made
<strong>{{ $formatMoney(auth.user.payout_data.balance) }}</strong
>, which is under the minimum of ${{ minWithdraw }} to withdraw.
</p>
<div v-if="!enrolled">
<NuxtLink class="iconified-button" to="/settings/monetization">
<SettingsIcon /> Enroll in the Creator Monetization Program
</NuxtLink>
</div>
<div v-if="enrolled" class="input-group">
<button
<div class="input-group">
<nuxt-link
v-if="auth.user.payout_data.balance >= minWithdraw"
class="iconified-button brand-button"
@click="$refs.modal_transfer.show()"
to="/dashboard/revenue/withdraw"
>
<TransferIcon /> Transfer to
{{ $formatWallet(auth.user.payout_data.payout_wallet) }}
</button>
<TransferIcon /> Withdraw
</nuxt-link>
<NuxtLink class="iconified-button" to="/dashboard/revenue/transfers">
<HistoryIcon /> View transfer history
</NuxtLink>
<NuxtLink class="iconified-button" to="/settings/monetization">
<SettingsIcon /> Monetization settings
</NuxtLink>
</div>
</section>
<section class="universal-card">
<h2>About the program</h2>
<p>
By uploading projects to Modrinth and withdrawing money from your account, you agree to the
<nuxt-link to="/legal/cmp" class="text-link">Rewards Program Terms</nuxt-link>. For more
information on how the rewards system works, see our information page
<nuxt-link to="/legal/cmp-info" class="text-link">here</nuxt-link>.
</p>
<h2>Processing fees</h2>
<p>
To avoid paying unnecessary fee deductions, you may want to wait to transfer your money out
after it accumulates for a bit rather than transferring as soon as you reach the minimum of
${{ minWithdraw }}.
</p>
</section>
<section class="universal-card">
<h2>Payout methods</h2>
<h3>PayPal</h3>
<ul>
<li>
In the <strong>United States</strong>, PayPal charges a flat
<strong>$0.25</strong>
fee per transaction.
</li>
<li>
In the rest of the world, PayPal charges a <strong>2%</strong> (up to $20) fee per
transaction.
</li>
</ul>
<template v-if="auth.user.auth_providers.includes('paypal')">
<p>
Your PayPal {{ auth.user.payout_data.paypal_country }} account is currently connected with
email
{{ auth.user.payout_data.paypal_address }}
</p>
<button class="btn" @click="removeAuthProvider('paypal')">
<XIcon /> Disconnect account
</button>
</template>
<template v-else>
<p>Connect your PayPal account to enable withdrawing to your PayPal balance.</p>
<a class="btn" :href="`${getAuthUrl('paypal')}&token=${auth.token}`">
<PayPalIcon />
Sign in with PayPal
</a>
</template>
<h3>Tremendous</h3>
<p>
Modrinth will deduct <strong>2%</strong> for the fee (minimum of $0.25 and maximum of $20)
from <strong>all transfers</strong> and if the fee PayPal charges is less than the amount we
deducted, the difference will be added back to your Modrinth balance. This happens as
Modrinth cannot determine if a transaction will be in the United States or international or
not until after the transaction has been made.
</p>
<h3>Venmo (United States only)</h3>
<p>
Venmo will charge a $0.25 processing fee per transaction, which will be deducted from the
amount you choose to transfer.
</p>
<h2>Currency conversions</h2>
<p>
All revenue generated by Modrinth is in United States dollars. Any conversions to your local
currency will happen at withdrawal and is not handled by Modrinth. Modrinth cannot guarantee
any exchange rate, so only USD is displayed in the creator dashboard.
Tremendous payments are sent to your Modrinth email. To change/set your Modrinth email,
visit
<nuxt-link to="/settings/account" class="text-link">here</nuxt-link>.
</p>
<h3>Venmo</h3>
<p>Enter your Venmo username below to enable withdrawing to your Venmo balance.</p>
<label class="hidden" for="venmo">Venmo address</label>
<input
id="venmo"
v-model="auth.user.payout_data.venmo_handle"
type="search"
name="search"
placeholder="@example"
autocomplete="off"
/>
<button class="btn btn-secondary" @click="updateVenmo"><SaveIcon /> Save information</button>
</section>
</div>
</template>
<script>
import TransferIcon from '~/assets/images/utils/transfer.svg'
import SettingsIcon from '~/assets/images/utils/settings.svg'
import HistoryIcon from '~/assets/images/utils/history.svg'
import ModalTransfer from '~/components/ui/ModalTransfer.vue'
<script setup>
import { TransferIcon, HistoryIcon, PayPalIcon, SaveIcon, XIcon } from 'omorphia'
export default defineNuxtComponent({
components: { TransferIcon, SettingsIcon, HistoryIcon, ModalTransfer },
async setup() {
const auth = await useAuth()
const auth = await useAuth()
const minWithdraw = ref(0.1)
return { auth }
},
data() {
return {
minWithdraw: 0.26,
enrolled:
this.auth.user.payout_data.payout_wallet &&
this.auth.user.payout_data.payout_wallet_type &&
this.auth.user.payout_data.payout_address,
async function updateVenmo() {
startLoading()
try {
const data = {
venmo_handle: auth.value.user.payout_data.venmo_handle ?? null,
}
},
head: {
title: 'Revenue - Modrinth',
},
methods: {},
})
await useBaseFetch(`user/${auth.value.user.id}`, {
method: 'PATCH',
body: data,
apiVersion: 3,
})
await useAuth(auth.value.token)
} catch (err) {
const data = useNuxtApp()
data.$notify({
group: 'main',
title: 'An error occurred',
text: err.data.description,
type: 'error',
})
}
stopLoading()
}
</script>
<style lang="scss" scoped>
strong {

View File

@ -6,130 +6,133 @@
:link-stack="[{ href: '/dashboard/revenue', label: 'Revenue' }]"
/>
<h2>Transfer history</h2>
<p>
All of your transfers from your Modrinth balance to your PayPal or Venmo accounts will be
listed here:
</p>
<div class="grid-table">
<div class="grid-table__row grid-table__header">
<div class="desktop">Date</div>
<div class="desktop">Status</div>
<div class="desktop">Amount</div>
<div class="mobile">Transaction</div>
<p>All of your withdrawals from your Modrinth balance will be listed here:</p>
<div v-for="payout in sortedPayouts" :key="payout.id" class="universal-card recessed payout">
<div class="platform">
<PayPalIcon v-if="payout.method === 'paypal'" />
<TremendousIcon v-else-if="payout.method === 'tremendous'" />
<VenmoIcon v-else-if="payout.method === 'venmo'" />
<UnknownIcon v-else />
</div>
<div
v-for="(payout, index) in payouts.payouts.filter((x) => x.status === 'success')"
:key="`payout-${index}`"
class="grid-table__row"
>
<div>{{ $dayjs(payout.created).format('MMMM D, YYYY [at] h:mm A') }}</div>
<div><Badge :type="payout.status" /></div>
<div class="amount">{{ $formatMoney(payout.amount) }}</div>
<div class="payout-info">
<div>
<strong>
{{ $dayjs(payout.created).format('MMMM D, YYYY [at] h:mm A') }}
</strong>
</div>
<div>
<span class="amount">{{ $formatMoney(payout.amount) }}</span>
<template v-if="payout.fee"> Fee {{ $formatMoney(payout.fee) }}</template>
</div>
<div class="payout-status">
<span>
<Badge v-if="payout.status === 'success'" color="green" type="Success" />
<Badge v-else-if="payout.status === 'cancelling'" color="yellow" type="Cancelling" />
<Badge v-else-if="payout.status === 'cancelled'" color="red" type="Cancelled" />
<Badge v-else-if="payout.status === 'failed'" color="red" type="Failed" />
<Badge v-else-if="payout.status === 'in-transit'" color="yellow" type="In transit" />
<Badge v-else :type="payout.status" />
</span>
<template v-if="payout.method">
<span></span>
<span>{{ $formatWallet(payout.method) }} ({{ payout.method_address }})</span>
</template>
</div>
</div>
<div class="input-group">
<button
v-if="payout.status === 'in-transit'"
class="iconified-button raised-button"
@click="cancelPayout(payout.id)"
>
<XIcon /> Cancel payment
</button>
</div>
</div>
</section>
</div>
</template>
<script setup>
import Badge from '~/components/ui/Badge.vue'
import Breadcrumbs from '~/components/ui/Breadcrumbs.vue'
import { Badge, Breadcrumbs, XIcon, PayPalIcon, UnknownIcon } from 'omorphia'
import dayjs from 'dayjs'
import TremendousIcon from '~/assets/images/external/tremendous.svg'
import VenmoIcon from '~/assets/images/external/venmo-small.svg'
useHead({
title: 'Transfer history - Modrinth',
})
const data = await useNuxtApp()
const auth = await useAuth()
const { data: payouts } = await useAsyncData(`user/${auth.value.user.id}/payouts`, () =>
useBaseFetch(`user/${auth.value.user.id}/payouts`)
const { data: payouts, refresh } = await useAsyncData(`payout`, () =>
useBaseFetch(`payout`, {
apiVersion: 3,
})
)
const sortedPayouts = computed(() =>
payouts.value.sort((a, b) => dayjs(b.created) - dayjs(a.created))
)
async function cancelPayout(id) {
startLoading()
try {
await useBaseFetch(`payout/${id}`, {
method: 'DELETE',
apiVersion: 3,
})
await refresh()
await useAuth(auth.value.token)
} catch (err) {
console.log(err)
data.$notify({
group: 'main',
title: 'An error occurred',
text: err.data.description,
type: 'error',
})
}
stopLoading()
}
</script>
<style lang="scss" scoped>
.grid-table {
display: grid;
grid-template-columns: auto auto auto;
border-radius: var(--size-rounded-sm);
overflow: hidden;
margin-top: var(--spacing-card-md);
.payout {
display: flex;
flex-direction: column;
gap: 0.5rem;
.grid-table__header {
.mobile {
display: none;
}
}
.grid-table__row {
display: contents;
> div {
display: flex;
flex-direction: column;
justify-content: center;
padding: var(--spacing-card-sm);
// Left edge of table
&:first-child,
&.mobile {
padding-left: var(--spacing-card-bg);
}
// Right edge of table
&:last-child {
padding-right: var(--spacing-card-bg);
}
}
&:nth-child(2n + 1) > div {
background-color: var(--color-table-alternate-row);
}
> div {
padding-top: var(--spacing-card-bg);
padding-bottom: var(--spacing-card-bg);
}
&.grid-table__header > div {
background-color: var(--color-bg);
font-weight: bold;
color: var(--color-text-dark);
}
}
@media screen and (max-width: 560px) {
.platform {
display: flex;
flex-direction: column;
.grid-table__row {
display: flex;
flex-direction: column;
padding: 0.75rem;
background-color: var(--color-raised-bg);
width: fit-content;
height: fit-content;
border-radius: 20rem;
> div {
padding: var(--spacing-card-xs) var(--spacing-card-bg);
&:first-child,
&.mobile {
padding-top: var(--spacing-card-bg);
}
&:last-child,
&.mobile {
padding-bottom: var(--spacing-card-bg);
}
}
svg {
width: 2rem;
height: 2rem;
}
}
.grid-table__header {
.mobile {
display: flex;
}
.desktop {
display: none;
}
}
.payout-status {
display: flex;
gap: 0.5ch;
}
.amount {
color: var(--color-heading);
font-weight: 500;
}
@media screen and (min-width: 800px) {
flex-direction: row;
align-items: center;
.input-group {
margin-left: auto;
}
}
}
</style>

View File

@ -0,0 +1,442 @@
<template>
<section class="universal-card">
<Breadcrumbs
current-title="Withdraw"
:link-stack="[{ href: '/dashboard/revenue', label: 'Revenue' }]"
/>
<h2>Withdraw</h2>
<h3>Country</h3>
<Multiselect
id="country-multiselect"
v-model="country"
class="country-multiselect"
placeholder="Select country..."
track-by="id"
label="name"
:options="countries"
:searchable="true"
:close-on-select="true"
:show-labels="false"
/>
<h3>Withdraw method</h3>
<div class="iconified-input">
<label class="hidden" for="search">Search</label>
<SearchIcon aria-hidden="true" />
<input
id="search"
v-model="search"
name="search"
placeholder="Search options..."
autocomplete="off"
/>
</div>
<div class="withdraw-options-scroll">
<div class="withdraw-options">
<button
v-for="method in payoutMethods.filter((x) =>
x.name.toLowerCase().includes(search.toLowerCase())
)"
:key="method.id"
class="withdraw-option button-base"
:class="{ selected: selectedMethodId === method.id }"
@click="() => (selectedMethodId = method.id)"
>
<div class="preview" :class="{ 'show-bg': !method.image_url || method.name === 'ACH' }">
<img
v-if="method.image_url && method.name !== 'ACH'"
class="preview-img"
:src="method.image_url"
:alt="method.name"
/>
<div v-else class="placeholder">
<template v-if="method.type === 'venmo'">
<VenmoIcon class="enlarge" />
</template>
<template v-else>
<PayPalIcon v-if="method.type === 'paypal'" />
<span>{{ method.name }}</span>
</template>
</div>
</div>
<div class="label">
<RadioButtonChecked v-if="selectedMethodId === method.id" class="radio" />
<RadioButtonIcon v-else class="radio" />
<span>{{ method.name }}</span>
</div>
</button>
</div>
</div>
<h3>Amount</h3>
<p>
You are initiating a transfer of your revenue from Modrinth's Creator Monetization Program.
How much of your
<strong>{{ $formatMoney(auth.user.payout_data.balance) }}</strong> balance would you like to
transfer to {{ selectedMethod.name }}?
</p>
<div class="confirmation-input">
<template v-if="selectedMethod.interval.fixed">
<Chips
v-model="amount"
:items="selectedMethod.interval.fixed.values"
:format-label="(val) => '$' + val"
/>
</template>
<template v-else>
<input
id="confirmation"
v-model="amount"
type="text"
pattern="^\d*(\.\d{0,2})?$"
autocomplete="off"
placeholder="Amount to transfer..."
/>
</template>
</div>
<div class="confirm-text">
<template v-if="knownErrors.length === 0 && amount">
<Checkbox v-if="fees > 0" v-model="agreedFees" description="Consent to fee">
I acknowledge that an estimated
{{ $formatMoney(fees) }} will be deducted from the amount I receive to cover
{{ $formatWallet(selectedMethod.type) }} processing fees.
</Checkbox>
<Checkbox v-model="agreedTransfer" description="Confirm transfer">
<template v-if="selectedMethod.type === 'tremendous'">
I confirm that I am initiating a transfer and I will receive further instructions on how
to redeem this payment via email to: {{ withdrawAccount }}
</template>
<template v-else>
I confirm that I am initiating a transfer to the following
{{ $formatWallet(selectedMethod.type) }} account: {{ withdrawAccount }}
</template>
</Checkbox>
<Checkbox v-model="agreedTerms" class="rewards-checkbox">
I agree to the
<nuxt-link to="/legal/cmp" class="text-link">Rewards Program Terms</nuxt-link>
</Checkbox>
</template>
<template v-else>
<span v-for="(error, index) in knownErrors" :key="index" class="invalid">
{{ error }}
</span>
</template>
</div>
<div class="button-group">
<nuxt-link to="/dashboard/revenue" class="iconified-button">
<XIcon />
Cancel
</nuxt-link>
<button
:disabled="
knownErrors.length > 0 ||
!amount ||
!agreedTransfer ||
!agreedTerms ||
(fees > 0 && !agreedFees)
"
class="iconified-button brand-button"
@click="withdraw"
>
<TransferIcon />
Withdraw
</button>
</div>
</section>
</template>
<script setup>
import { Multiselect } from 'vue-multiselect'
import {
PayPalIcon,
SearchIcon,
RadioButtonIcon,
RadioButtonChecked,
Chips,
XIcon,
TransferIcon,
Checkbox,
Breadcrumbs,
} from 'omorphia'
import { all } from 'iso-3166-1'
import VenmoIcon from '~/assets/images/external/venmo.svg'
const auth = await useAuth()
const data = useNuxtApp()
const countries = computed(() =>
all().map((x) => ({
id: x.alpha2,
name: x.alpha2 === 'TW' ? 'Taiwan' : x.country,
}))
)
const search = ref('')
const amount = ref('')
const country = ref(
countries.value.find((x) => x.id === (auth.value.user.payout_data.paypal_region ?? 'US'))
)
const { data: payoutMethods, refresh: refreshPayoutMethods } = await useAsyncData(
`payout/methods?country=${country.value.id}`,
() => useBaseFetch(`payout/methods?country=${country.value.id}`, { apiVersion: 3 })
)
const selectedMethodId = ref(payoutMethods.value[0].id)
const selectedMethod = computed(() =>
payoutMethods.value.find((x) => x.id === selectedMethodId.value)
)
const parsedAmount = computed(() => {
const regex = /^\$?(\d*(\.\d{2})?)$/gm
const matches = regex.exec(amount.value)
console.log(matches)
return matches && matches[1] ? parseFloat(matches[1]) : 0.0
})
const fees = computed(() => {
return Math.min(
Math.max(
selectedMethod.value.fee.min,
selectedMethod.value.fee.percentage * parsedAmount.value
),
selectedMethod.value.fee.max ?? Number.MAX_VALUE
)
})
const withdrawAccount = computed(() => {
if (selectedMethod.value.type === 'paypal') {
return auth.value.user.payout_data.paypal_address
} else if (selectedMethod.value.type === 'venmo') {
return auth.value.user.payout_data.venmo_handle
} else {
return auth.value.user.email
}
})
const knownErrors = computed(() => {
const errors = []
if (selectedMethod.value.type === 'paypal' && !auth.value.user.payout_data.paypal_address) {
errors.push('Please link your PayPal account in the dashboard to proceed.')
}
if (selectedMethod.value.type === 'venmo' && !auth.value.user.payout_data.venmo_handle) {
errors.push('Please set your Venmo handle in the dashboard to proceed.')
}
if (selectedMethod.value.type === 'tremendous') {
if (!auth.value.user.email) {
errors.push('Please set your email address in your account settings to proceed.')
}
if (!auth.value.user.email_verified) {
errors.push('Please verify your email address to proceed.')
}
}
if (!parsedAmount.value && amount.value.length > 0) {
errors.push(`${amount.value} is not a valid amount`)
} else if (parsedAmount.value > auth.value.user.payout_data.balance) {
errors.push(
`The amount must be no more than ${data.$formatMoney(auth.value.user.payout_data.balance)}`
)
} else if (parsedAmount.value <= fees.value) {
errors.push(`The amount must be at least ${data.$formatMoney(fees.value + 0.01)}`)
}
return errors
})
const agreedTransfer = ref(false)
const agreedFees = ref(false)
const agreedTerms = ref(false)
watch(country, async () => {
await refreshPayoutMethods()
console.log(payoutMethods.value)
if (payoutMethods.value && payoutMethods.value[0]) {
selectedMethodId.value = payoutMethods.value[0].id
}
})
async function withdraw() {
startLoading()
try {
const auth = await useAuth()
await useBaseFetch(`payout`, {
method: 'POST',
body: {
amount: parsedAmount.value,
method: selectedMethod.value.type,
method_id: selectedMethod.value.id,
},
apiVersion: 3,
})
await useAuth(auth.value.token)
await navigateTo('/dashboard/revenue')
data.$notify({
group: 'main',
title: 'Withdrawal complete',
text:
selectedMethod.value.type === 'tremendous'
? 'An email has been sent to your account with further instructions on how to redeem your payout!'
: `Payment has been sent to your ${data.$formatWallet(
selectedMethod.value.type
)} account!`,
type: 'success',
})
} catch (err) {
console.log(err)
data.$notify({
group: 'main',
title: 'An error occurred',
text: err.data.description,
type: 'error',
})
}
stopLoading()
}
</script>
<style lang="scss" scoped>
.withdraw-options-scroll {
max-height: 460px;
overflow-y: auto;
&::-webkit-scrollbar {
width: var(--gap-md);
border: 3px solid var(--color-bg);
}
&::-webkit-scrollbar-track {
background: var(--color-bg);
border: 3px solid var(--color-bg);
}
&::-webkit-scrollbar-thumb {
background-color: var(--color-raised-bg);
border-radius: var(--radius-lg);
border: 3px solid var(--color-bg);
}
}
.withdraw-options {
display: grid;
grid-template-columns: repeat(1, 1fr);
gap: var(--gap-lg);
padding-right: 0.5rem;
@media screen and (min-width: 300px) {
grid-template-columns: repeat(2, 1fr);
}
@media screen and (min-width: 600px) {
grid-template-columns: repeat(3, 1fr);
}
}
.withdraw-option {
width: 100%;
border-radius: var(--radius-md);
padding: 0;
overflow: hidden;
border: 1px solid var(--color-divider);
background-color: var(--color-button-bg);
color: var(--color-text);
&.selected {
color: var(--color-contrast);
.label svg {
color: var(--color-brand);
}
}
.preview {
display: flex;
justify-content: center;
aspect-ratio: 30 / 19;
&.show-bg {
background-color: var(--color-bg);
}
img {
-webkit-user-drag: none;
-khtml-user-drag: none;
-moz-user-drag: none;
-o-user-drag: none;
user-drag: none;
user-select: none;
width: 100%;
height: auto;
object-fit: cover;
}
.placeholder {
display: flex;
align-items: center;
gap: var(--gap-xs);
svg {
width: 2rem;
height: auto;
}
span {
font-weight: var(--font-weight-bold);
font-size: 2rem;
font-style: italic;
}
.enlarge {
width: auto;
height: 1.5rem;
}
}
}
.label {
display: flex;
align-items: center;
padding: var(--gap-md) var(--gap-lg);
svg {
min-height: 1rem;
min-width: 1rem;
margin-right: 0.5rem;
}
span {
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
}
}
}
.invalid {
color: var(--color-special-red);
}
.confirm-text {
margin: var(--spacing-card-md) 0;
display: flex;
flex-direction: column;
gap: var(--spacing-card-sm);
}
.iconified-input {
margin-bottom: var(--spacing-card-md);
}
.country-multiselect,
.iconified-input {
max-width: 16rem;
}
.rewards-checkbox {
a {
margin-left: 0.5ch;
}
}
</style>

View File

@ -21,9 +21,6 @@
<NavStackItem link="/settings/sessions" :label="formatMessage(messages.sessionsTitle)">
<ShieldIcon />
</NavStackItem>
<NavStackItem link="/settings/monetization" label="Monetization">
<CurrencyIcon />
</NavStackItem>
</template>
<template v-if="auth.user">
<h3>Developer Settings</h3>
@ -49,7 +46,6 @@ import NavStackItem from '~/components/ui/NavStackItem.vue'
import PaintbrushIcon from '~/assets/images/utils/paintbrush.svg'
import UserIcon from '~/assets/images/utils/user.svg'
import CurrencyIcon from '~/assets/images/utils/currency.svg'
import ShieldIcon from '~/assets/images/utils/shield.svg'
import KeyIcon from '~/assets/images/utils/key.svg'
import LanguagesIcon from '~/assets/images/utils/languages.svg'

View File

@ -596,27 +596,6 @@ const authProviders = [
},
]
async function removeAuthProvider(provider) {
startLoading()
try {
await useBaseFetch('auth/provider', {
method: 'DELETE',
body: {
provider,
},
})
await useAuth(auth.value.token)
} catch (err) {
data.$notify({
group: 'main',
title: 'An error occurred',
text: err.data.description,
type: 'error',
})
}
stopLoading()
}
async function deleteAccount() {
startLoading()
try {

View File

@ -1,235 +0,0 @@
<template>
<div>
<section v-if="enrolled" class="universal-card">
<h2>Revenue and metrics</h2>
<p>View your revenue and metrics in the creator dashboard:</p>
<NuxtLink class="iconified-button" to="/dashboard/revenue">
<ChartIcon /> Visit creator dashboard
</NuxtLink>
</section>
<section class="universal-card">
<h2 class="title">Enrollment</h2>
<template v-if="!enrolled && !auth.user.email">
<p v-if="!enrolled">
You are not currently enrolled in Modrinth's Creator Monetization Program. In order to
enroll, you must first add a valid email address to your account.
</p>
<NuxtLink class="iconified-button" to="/settings/account">
<SettingsIcon /> Visit account settings
</NuxtLink>
</template>
<template v-else-if="editing || !enrolled">
<p v-if="!enrolled">
You are not currently enrolled in Modrinth's Creator Monetization Program. Setup a method
of receiving payments below to enable monetization.
</p>
<div class="enroll universal-body">
<Chips
v-model="selectedWallet"
:starting-value="selectedWallet"
:items="wallets"
:format-label="$formatWallet"
@update:model-value="onChangeWallet()"
/>
<p>
Enter the information for the
{{ $formatWallet(selectedWallet) }} account you would like to receive your revenue from
the Creator Monetization Program:
</p>
<div class="input-group">
<Multiselect
v-model="accountType"
:options="getAccountTypes()"
:custom-label="(value) => formatAccountType(value)"
:searchable="false"
:close-on-select="true"
:show-labels="false"
:allow-empty="false"
/>
<label class="hidden" for="account-input"
>{{ $formatWallet(selectedWallet) }}
{{ formatAccountType(accountType).toLowerCase() }} input field</label
>
<input
id="account-input"
v-model="account"
class="account-input"
:placeholder="`Enter your ${$formatWallet(selectedWallet)} ${formatAccountType(
accountType
).toLowerCase()}...`"
:type="accountType === 'email' ? 'email' : ''"
/>
<span v-if="accountType === 'phone'"> Format: +18888888888 or +1-888-888-8888 </span>
</div>
<div class="rewards-checkbox">
<Checkbox v-model="agreed">
I agree to the
<nuxt-link to="/legal/cmp" class="goto-link">Rewards Program Terms</nuxt-link>
</Checkbox>
</div>
<div class="input-group">
<button
:disabled="!agreed"
class="iconified-button brand-button"
@click="updatePayoutData(false)"
>
<SaveIcon /> Save information
</button>
<button
v-if="enrolled"
class="iconified-button danger-button"
@click="updatePayoutData(true)"
>
<TrashIcon /> Remove enrollment
</button>
</div>
</div>
</template>
<template v-else>
<p>
You are currently enrolled in the Creator Monetization Program with a
{{ $formatWallet(selectedWallet) }} account.
</p>
<button class="iconified-button brand-button" @click="editing = true">
<EditIcon /> Edit information
</button>
</template>
</section>
</div>
</template>
<script>
import { Checkbox } from 'omorphia'
import { Multiselect } from 'vue-multiselect'
import Chips from '~/components/ui/Chips.vue'
import SaveIcon from '~/assets/images/utils/save.svg'
import TrashIcon from '~/assets/images/utils/trash.svg'
import EditIcon from '~/assets/images/utils/edit.svg'
import ChartIcon from '~/assets/images/utils/chart.svg'
import SettingsIcon from '~/assets/images/utils/settings.svg'
export default defineNuxtComponent({
components: {
Multiselect,
Chips,
SaveIcon,
TrashIcon,
EditIcon,
ChartIcon,
SettingsIcon,
Checkbox,
},
async setup() {
definePageMeta({
middleware: 'auth',
})
const auth = await useAuth()
return { auth }
},
data() {
return {
agreed: false,
editing: false,
enrolled:
this.auth.user.payout_data.payout_wallet &&
this.auth.user.payout_data.payout_wallet_type &&
this.auth.user.payout_data.payout_address,
wallets: ['paypal', 'venmo'],
selectedWallet: this.auth.user.payout_data.payout_wallet ?? 'paypal',
accountType: this.auth.user.payout_data.payout_wallet_type ?? this.getAccountTypes()[0],
account: this.auth.user.payout_data.payout_address ?? '',
}
},
head: {
title: 'Monetization settings - Modrinth',
},
methods: {
getAccountTypes() {
const types = []
if (this.selectedWallet === 'venmo') {
types.push('user_handle')
}
types.push('email')
types.push('phone')
return types
},
formatAccountType(value) {
switch (value) {
case 'email':
return 'Email address'
case 'phone':
return 'Phone number'
case 'user_handle':
return 'Username'
default:
return value.charAt(0).toUpperCase() + value.slice(1)
}
},
onChangeWallet() {
this.account = ''
// Set default account type for each wallet
if (this.selectedWallet === 'paypal') {
this.accountType = 'email'
} else if (this.selectedWallet === 'venmo') {
this.accountType = 'user_handle'
}
},
async updatePayoutData(unenroll) {
startLoading()
if (unenroll) {
this.selectedWallet = 'paypal'
this.accountType = this.getAccountTypes()[0]
this.account = ''
}
try {
const data = {
payout_data: unenroll
? null
: {
payout_wallet: this.selectedWallet,
payout_wallet_type: this.accountType,
payout_address: this.account,
},
}
await useBaseFetch(`user/${this.auth.user.id}`, {
method: 'PATCH',
body: data,
})
await useAuth(this.auth.token)
this.editing = false
this.enrolled = !unenroll
} catch (err) {
this.$notify({
group: 'main',
title: 'An error occurred',
text: err.data.description,
type: 'error',
})
}
stopLoading()
},
},
})
</script>
<style lang="scss" scoped>
.multiselect {
max-width: 15rem;
}
.account-input {
flex: 1;
}
.rewards-checkbox {
margin-bottom: 0.75rem;
a {
margin-left: 0.5ch;
}
}
</style>

View File

@ -129,7 +129,7 @@ async function revokeSession(id) {
stopLoading()
}
</script>
<style lang="scss">
<style lang="scss" socped>
.session {
display: flex;
flex-direction: column;

7
pnpm-lock.yaml generated
View File

@ -25,6 +25,9 @@ dependencies:
highlight.js:
specifier: ^11.7.0
version: 11.7.0
iso-3166-1:
specifier: ^2.1.1
version: 2.1.1
js-yaml:
specifier: ^4.1.0
version: 4.1.0
@ -5612,6 +5615,10 @@ packages:
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
dev: true
/iso-3166-1@2.1.1:
resolution: {integrity: sha512-RZxXf8cw5Y8LyHZIwIRvKw8sWTIHh2/txBT+ehO0QroesVfnz3JNFFX4i/OC/Yuv2bDIVYrHna5PMvjtpefq5w==}
dev: false
/jackspeak@2.2.1:
resolution: {integrity: sha512-MXbxovZ/Pm42f6cDIDkl3xpwv1AGwObKwfmjs2nQePiy85tP3fatofl3FC1aBsOtP/6fq5SbtgHwWcMsLP+bDw==}
engines: {node: '>=14'}