Files
AstralRinth/pages/dashboard/revenue/withdraw.vue
Geometrically 2d14e5682d New payouts system (#1456)
* initial

* finish withdraw page

* Finish frontend

* Fix UI a bit

* tester fixes
2023-12-05 12:13:27 -07:00

443 lines
11 KiB
Vue

<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>