538 lines
15 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<section class="universal-card">
<Breadcrumbs
current-title="Withdraw"
:link-stack="[{ href: '/dashboard/revenue', label: 'Revenue' }]"
/>
<h2>Withdraw</h2>
<h3>Region</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"
:allow-empty="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()))
.sort((a, b) =>
a.type !== 'tremendous'
? -1
: a.name.toLowerCase().localeCompare(b.name.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' }">
<template v-if="method.image_url && method.name !== 'ACH'">
<div class="preview-badges">
<span class="badge">
{{
getRangeOfMethod(method)
.map($formatMoney)
.map((i) => i.replace(".00", ""))
.join("")
}}
</span>
</div>
<img
v-if="method.image_url && method.name !== 'ACH'"
class="preview-img"
:src="method.image_url"
:alt="method.name"
/>
</template>
<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">
<RadioButtonCheckedIcon 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(userBalance.available) }}</strong> balance would you like to transfer
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-if="minWithdrawAmount == maxWithdrawAmount">
<div>
<p>
This method has a fixed transfer amount of
<strong>{{ $formatMoney(minWithdrawAmount) }}</strong
>.
</p>
</div>
</template>
<template v-else>
<div>
<p>
This method has a minimum transfer amount of
<strong>{{ $formatMoney(minWithdrawAmount) }}</strong> and a maximum transfer amount of
<strong>{{ $formatMoney(maxWithdrawAmount) }}</strong
>.
</p>
<input
id="confirmation"
v-model="amount"
type="text"
pattern="^\d*(\.\d{0,2})?$"
autocomplete="off"
placeholder="Amount to transfer..."
/>
<p>
You have entered <strong>{{ $formatMoney(parsedAmount) }}</strong> to transfer.
</p>
</div>
</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,
RadioButtonCheckedIcon,
XIcon,
TransferIcon,
} from "@modrinth/assets";
import { Chips, Checkbox, Breadcrumbs } from "@modrinth/ui";
import { all } from "iso-3166-1";
import { formatMoney, formatWallet } from "@modrinth/utils";
import VenmoIcon from "~/assets/images/external/venmo.svg?component";
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: userBalance }, { data: payoutMethods, refresh: refreshPayoutMethods }] =
await Promise.all([
useAsyncData(`payout/balance`, () => useBaseFetch(`payout/balance`, { apiVersion: 3 })),
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);
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 getIntervalRange = (intervalType) => {
if (!intervalType) {
return [];
}
const { min, max, values } = intervalType;
if (values) {
const first = values[0];
const last = values.slice(-1)[0];
return first === last ? [first] : [first, last];
}
return min === max ? [min] : [min, max];
};
const getRangeOfMethod = (method) => {
return getIntervalRange(method.interval?.fixed || method.interval?.standard);
};
const maxWithdrawAmount = computed(() => {
const interval = selectedMethod.value.interval;
return interval?.standard ? interval.standard.max : (interval?.fixed?.values.slice(-1)[0] ?? 0);
});
const minWithdrawAmount = computed(() => {
const interval = selectedMethod.value.interval;
return interval?.standard ? interval.standard.min : (interval?.fixed?.values?.[0] ?? fees.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 > userBalance.value.available ||
parsedAmount.value > maxWithdrawAmount.value
) {
const maxAmount = Math.min(userBalance.value.available, maxWithdrawAmount.value);
errors.push(`The amount must be no more than ${data.$formatMoney(maxAmount)}`);
} else if (parsedAmount.value <= fees.value || parsedAmount.value < minWithdrawAmount.value) {
const minAmount = Math.max(fees.value + 0.01, minWithdrawAmount.value);
errors.push(`The amount must be at least ${data.$formatMoney(minAmount)}`);
}
return errors;
});
const agreedTransfer = ref(false);
const agreedFees = ref(false);
const agreedTerms = ref(false);
watch(country, async () => {
await refreshPayoutMethods();
if (payoutMethods.value && payoutMethods.value[0]) {
selectedMethodId.value = payoutMethods.value[0].id;
}
});
watch(selectedMethod, () => {
if (selectedMethod.value.interval?.fixed) {
amount.value = selectedMethod.value.interval.fixed.values[0];
}
if (maxWithdrawAmount.value === minWithdrawAmount.value) {
amount.value = maxWithdrawAmount.value;
}
agreedTransfer.value = false;
agreedFees.value = false;
agreedTerms.value = false;
});
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 ${formatWallet(selectedMethod.value.type)} account!`,
type: "success",
});
} catch (err) {
data.$notify({
group: "main",
title: "An error occurred",
text: err.data ? err.data.description : err,
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;
position: relative;
.preview-badges {
// These will float over the image in the bottom right corner
position: absolute;
bottom: 0;
right: 0;
padding: var(--gap-sm) var(--gap-xs);
.badge {
background-color: var(--color-button-bg);
border-radius: var(--radius-xs);
padding: var(--gap-xs) var(--gap-sm);
font-size: var(--font-size-xs);
}
}
&.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-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>