458 lines
12 KiB
Vue
458 lines
12 KiB
Vue
|
|
<template>
|
||
|
|
<NewModal
|
||
|
|
ref="withdrawModal"
|
||
|
|
:closable="currentStage !== 'completion'"
|
||
|
|
:hide-header="currentStage === 'completion'"
|
||
|
|
:merge-header="currentStage === 'completion'"
|
||
|
|
:scrollable="true"
|
||
|
|
max-content-height="72vh"
|
||
|
|
:on-hide="onModalHide"
|
||
|
|
>
|
||
|
|
<template #title>
|
||
|
|
<div v-if="shouldShowTitle" class="flex flex-wrap items-center gap-1 text-secondary">
|
||
|
|
<template v-if="currentStage === 'tax-form'">
|
||
|
|
<span class="text-lg font-bold text-contrast sm:text-xl">{{
|
||
|
|
formatMessage(messages.taxFormStage)
|
||
|
|
}}</span>
|
||
|
|
</template>
|
||
|
|
<template v-else-if="currentStage === 'method-selection'">
|
||
|
|
<span class="text-lg font-bold text-contrast sm:text-xl">{{
|
||
|
|
formatMessage(messages.methodSelectionStage)
|
||
|
|
}}</span>
|
||
|
|
<ChevronRightIcon class="size-5 text-secondary" stroke-width="3" />
|
||
|
|
<span class="text-lg text-secondary sm:text-xl">{{
|
||
|
|
formatMessage(messages.detailsLabel)
|
||
|
|
}}</span>
|
||
|
|
</template>
|
||
|
|
<template v-else-if="isDetailsStage">
|
||
|
|
<button
|
||
|
|
class="active:scale-9 bg-transparent p-0 text-lg text-secondary transition-colors duration-200 hover:text-primary sm:text-xl"
|
||
|
|
@click="goToBreadcrumbStage('method-selection')"
|
||
|
|
>
|
||
|
|
{{ formatMessage(messages.methodSelectionStage) }}
|
||
|
|
</button>
|
||
|
|
<ChevronRightIcon class="size-5 text-secondary" stroke-width="3" />
|
||
|
|
<span class="text-lg font-bold text-contrast sm:text-xl">{{
|
||
|
|
formatMessage(messages.detailsLabel)
|
||
|
|
}}</span>
|
||
|
|
</template>
|
||
|
|
</div>
|
||
|
|
</template>
|
||
|
|
<div class="w-full max-w-[496px] lg:min-w-[496px]">
|
||
|
|
<TaxFormStage
|
||
|
|
v-if="currentStage === 'tax-form'"
|
||
|
|
:balance="balance"
|
||
|
|
:on-show-tax-form="showTaxFormModal"
|
||
|
|
/>
|
||
|
|
<MethodSelectionStage
|
||
|
|
v-else-if="currentStage === 'method-selection'"
|
||
|
|
:on-show-tax-form="showTaxFormModal"
|
||
|
|
@close-modal="withdrawModal?.hide()"
|
||
|
|
/>
|
||
|
|
<TremendousDetailsStage v-else-if="currentStage === 'tremendous-details'" />
|
||
|
|
<MuralpayKycStage v-else-if="currentStage === 'muralpay-kyc'" />
|
||
|
|
<MuralpayDetailsStage v-else-if="currentStage === 'muralpay-details'" />
|
||
|
|
<LegacyPaypalDetailsStage v-else-if="currentStage === 'paypal-details'" />
|
||
|
|
<CompletionStage v-else-if="currentStage === 'completion'" />
|
||
|
|
<div v-else>Something went wrong</div>
|
||
|
|
</div>
|
||
|
|
<template #actions>
|
||
|
|
<div v-if="currentStage === 'completion'" class="mt-4 flex w-full gap-3">
|
||
|
|
<ButtonStyled class="flex-1">
|
||
|
|
<button class="w-full text-contrast" @click="handleClose">
|
||
|
|
{{ formatMessage(messages.closeButton) }}
|
||
|
|
</button>
|
||
|
|
</ButtonStyled>
|
||
|
|
<ButtonStyled class="flex-1">
|
||
|
|
<button class="w-full text-contrast" @click="handleViewTransactions">
|
||
|
|
{{ formatMessage(messages.transactionsButton) }}
|
||
|
|
</button>
|
||
|
|
</ButtonStyled>
|
||
|
|
</div>
|
||
|
|
<div v-else class="mt-4 flex flex-col justify-end gap-2 sm:flex-row">
|
||
|
|
<ButtonStyled type="outlined">
|
||
|
|
<button
|
||
|
|
class="!border-surface-5"
|
||
|
|
:disabled="leftButtonConfig.disabled"
|
||
|
|
@click="leftButtonConfig.handler"
|
||
|
|
>
|
||
|
|
<component :is="leftButtonConfig.icon" />
|
||
|
|
{{ leftButtonConfig.label }}
|
||
|
|
</button>
|
||
|
|
</ButtonStyled>
|
||
|
|
<ButtonStyled :color="rightButtonConfig.color">
|
||
|
|
<button :disabled="rightButtonConfig.disabled" @click="rightButtonConfig.handler">
|
||
|
|
<component
|
||
|
|
:is="rightButtonConfig.icon"
|
||
|
|
v-if="rightButtonConfig.iconPosition === 'before'"
|
||
|
|
:class="rightButtonConfig.iconClass"
|
||
|
|
/>
|
||
|
|
{{ rightButtonConfig.label }}
|
||
|
|
<component
|
||
|
|
:is="rightButtonConfig.icon"
|
||
|
|
v-if="rightButtonConfig.iconPosition === 'after'"
|
||
|
|
:class="rightButtonConfig.iconClass"
|
||
|
|
/>
|
||
|
|
</button>
|
||
|
|
</ButtonStyled>
|
||
|
|
</div>
|
||
|
|
</template>
|
||
|
|
</NewModal>
|
||
|
|
<CreatorTaxFormModal
|
||
|
|
ref="taxFormModal"
|
||
|
|
close-button-text="Continue"
|
||
|
|
@success="onTaxFormSuccess"
|
||
|
|
@cancelled="onTaxFormCancelled"
|
||
|
|
/>
|
||
|
|
</template>
|
||
|
|
|
||
|
|
<script setup lang="ts">
|
||
|
|
import {
|
||
|
|
ArrowLeftRightIcon,
|
||
|
|
ChevronRightIcon,
|
||
|
|
FileTextIcon,
|
||
|
|
LeftArrowIcon,
|
||
|
|
RightArrowIcon,
|
||
|
|
SpinnerIcon,
|
||
|
|
XIcon,
|
||
|
|
} from '@modrinth/assets'
|
||
|
|
import { ButtonStyled, commonMessages, injectNotificationManager, NewModal } from '@modrinth/ui'
|
||
|
|
import { defineMessages, useVIntl } from '@vintl/vintl'
|
||
|
|
import { computed, nextTick, onMounted, ref, useTemplateRef, watch } from 'vue'
|
||
|
|
|
||
|
|
import {
|
||
|
|
createWithdrawContext,
|
||
|
|
type PayoutMethod,
|
||
|
|
provideWithdrawContext,
|
||
|
|
TAX_THRESHOLD_ACTUAL,
|
||
|
|
type WithdrawStage,
|
||
|
|
} from '@/providers/creator-withdraw.ts'
|
||
|
|
|
||
|
|
import CreatorTaxFormModal from './CreatorTaxFormModal.vue'
|
||
|
|
import CompletionStage from './withdraw-stages/CompletionStage.vue'
|
||
|
|
import LegacyPaypalDetailsStage from './withdraw-stages/LegacyPaypalDetailsStage.vue'
|
||
|
|
import MethodSelectionStage from './withdraw-stages/MethodSelectionStage.vue'
|
||
|
|
import MuralpayDetailsStage from './withdraw-stages/MuralpayDetailsStage.vue'
|
||
|
|
import MuralpayKycStage from './withdraw-stages/MuralpayKycStage.vue'
|
||
|
|
import TaxFormStage from './withdraw-stages/TaxFormStage.vue'
|
||
|
|
import TremendousDetailsStage from './withdraw-stages/TremendousDetailsStage.vue'
|
||
|
|
|
||
|
|
type FormCompletionStatus = 'unknown' | 'unrequested' | 'unsigned' | 'tin-mismatch' | 'complete'
|
||
|
|
|
||
|
|
interface UserBalanceResponse {
|
||
|
|
available: number
|
||
|
|
withdrawn_lifetime: number
|
||
|
|
withdrawn_ytd: number
|
||
|
|
pending: number
|
||
|
|
dates: Record<string, number>
|
||
|
|
requested_form_type: string | null
|
||
|
|
form_completion_status: FormCompletionStatus | null
|
||
|
|
}
|
||
|
|
|
||
|
|
const props = defineProps<{
|
||
|
|
balance: UserBalanceResponse | null
|
||
|
|
preloadedPaymentData?: { country: string; methods: PayoutMethod[] } | null
|
||
|
|
}>()
|
||
|
|
|
||
|
|
const emit = defineEmits<{
|
||
|
|
(e: 'refresh-data' | 'hide'): void
|
||
|
|
}>()
|
||
|
|
|
||
|
|
const withdrawModal = useTemplateRef<InstanceType<typeof NewModal>>('withdrawModal')
|
||
|
|
const taxFormModal = ref<InstanceType<typeof CreatorTaxFormModal> | null>(null)
|
||
|
|
const isSubmitting = ref(false)
|
||
|
|
|
||
|
|
function show(preferred?: WithdrawStage) {
|
||
|
|
if (preferred) {
|
||
|
|
setStage(preferred, true)
|
||
|
|
withdrawModal.value?.show()
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
const firstStage = stages.value[0]
|
||
|
|
if (firstStage) {
|
||
|
|
setStage(firstStage, true)
|
||
|
|
}
|
||
|
|
|
||
|
|
withdrawModal.value?.show()
|
||
|
|
}
|
||
|
|
|
||
|
|
defineExpose({
|
||
|
|
show,
|
||
|
|
})
|
||
|
|
|
||
|
|
const { formatMessage } = useVIntl()
|
||
|
|
const { addNotification } = injectNotificationManager()
|
||
|
|
|
||
|
|
const withdrawContext = createWithdrawContext(
|
||
|
|
props.balance,
|
||
|
|
props.preloadedPaymentData || undefined,
|
||
|
|
)
|
||
|
|
provideWithdrawContext(withdrawContext)
|
||
|
|
|
||
|
|
const {
|
||
|
|
currentStage,
|
||
|
|
previousStep,
|
||
|
|
nextStep,
|
||
|
|
canProceed,
|
||
|
|
setStage,
|
||
|
|
withdrawData,
|
||
|
|
resetData,
|
||
|
|
stages,
|
||
|
|
submitWithdrawal,
|
||
|
|
restoreStateFromStorage,
|
||
|
|
clearSavedState,
|
||
|
|
} = withdrawContext
|
||
|
|
|
||
|
|
watch(
|
||
|
|
() => props.balance,
|
||
|
|
(newBalance) => {
|
||
|
|
if (newBalance) {
|
||
|
|
withdrawContext.balance.value = newBalance
|
||
|
|
}
|
||
|
|
},
|
||
|
|
{ deep: true },
|
||
|
|
)
|
||
|
|
|
||
|
|
onMounted(() => {
|
||
|
|
const route = useRoute()
|
||
|
|
const router = useRouter()
|
||
|
|
|
||
|
|
if (route.query.paypal_auth_return === 'true') {
|
||
|
|
const savedState = restoreStateFromStorage()
|
||
|
|
|
||
|
|
if (savedState?.data) {
|
||
|
|
withdrawData.value = { ...savedState.data }
|
||
|
|
|
||
|
|
nextTick(() => {
|
||
|
|
show(savedState.stage)
|
||
|
|
})
|
||
|
|
|
||
|
|
clearSavedState()
|
||
|
|
}
|
||
|
|
|
||
|
|
const query = { ...route.query }
|
||
|
|
delete query.paypal_auth_return
|
||
|
|
router.replace({ query })
|
||
|
|
}
|
||
|
|
})
|
||
|
|
|
||
|
|
const needsTaxForm = computed(() => {
|
||
|
|
if (!props.balance || currentStage.value !== 'tax-form') return false
|
||
|
|
const ytd = props.balance.withdrawn_ytd ?? 0
|
||
|
|
const available = props.balance.available ?? 0
|
||
|
|
const status = props.balance.form_completion_status
|
||
|
|
return status !== 'complete' && ytd + available >= 600
|
||
|
|
})
|
||
|
|
|
||
|
|
const remainingLimit = computed(() => {
|
||
|
|
if (!props.balance) return 0
|
||
|
|
const ytd = props.balance.withdrawn_ytd ?? 0
|
||
|
|
const raw = TAX_THRESHOLD_ACTUAL - ytd
|
||
|
|
if (raw <= 0) return 0
|
||
|
|
const cents = Math.floor(raw * 100)
|
||
|
|
return cents / 100
|
||
|
|
})
|
||
|
|
|
||
|
|
const leftButtonConfig = computed(() => {
|
||
|
|
if (previousStep.value) {
|
||
|
|
return {
|
||
|
|
icon: LeftArrowIcon,
|
||
|
|
label: formatMessage(commonMessages.backButton),
|
||
|
|
handler: () => setStage(previousStep.value, true),
|
||
|
|
disabled: isSubmitting.value,
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return {
|
||
|
|
icon: XIcon,
|
||
|
|
label: formatMessage(commonMessages.cancelButton),
|
||
|
|
handler: () => withdrawModal.value?.hide(),
|
||
|
|
disabled: isSubmitting.value,
|
||
|
|
}
|
||
|
|
})
|
||
|
|
|
||
|
|
const rightButtonConfig = computed(() => {
|
||
|
|
const stage = currentStage.value
|
||
|
|
const isTaxFormStage = stage === 'tax-form'
|
||
|
|
const isDetailsStage =
|
||
|
|
stage === 'muralpay-details' || stage === 'tremendous-details' || stage === 'paypal-details'
|
||
|
|
|
||
|
|
if (isTaxFormStage && needsTaxForm.value && remainingLimit.value > 0) {
|
||
|
|
return {
|
||
|
|
icon: RightArrowIcon,
|
||
|
|
label: formatMessage(messages.continueWithLimit),
|
||
|
|
handler: continueWithLimit,
|
||
|
|
disabled: false,
|
||
|
|
color: 'standard' as const,
|
||
|
|
iconPosition: 'after' as const,
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
if (isTaxFormStage && needsTaxForm.value) {
|
||
|
|
return {
|
||
|
|
icon: FileTextIcon,
|
||
|
|
label: formatMessage(messages.completeTaxForm),
|
||
|
|
handler: showTaxFormModal,
|
||
|
|
disabled: false,
|
||
|
|
color: 'orange' as const,
|
||
|
|
iconPosition: 'before' as const,
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
if (isDetailsStage) {
|
||
|
|
return {
|
||
|
|
icon: isSubmitting.value ? SpinnerIcon : ArrowLeftRightIcon,
|
||
|
|
iconClass: isSubmitting.value ? 'animate-spin' : undefined,
|
||
|
|
label: formatMessage(messages.withdrawButton),
|
||
|
|
handler: handleWithdraw,
|
||
|
|
disabled: !canProceed.value || isSubmitting.value,
|
||
|
|
color: 'brand' as const,
|
||
|
|
iconPosition: 'before' as const,
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
return {
|
||
|
|
icon: RightArrowIcon,
|
||
|
|
label: formatMessage(commonMessages.nextButton),
|
||
|
|
handler: () => setStage(nextStep.value),
|
||
|
|
disabled: !canProceed.value,
|
||
|
|
color: 'standard' as const,
|
||
|
|
iconPosition: 'after' as const,
|
||
|
|
}
|
||
|
|
})
|
||
|
|
|
||
|
|
function continueWithLimit() {
|
||
|
|
withdrawData.value.tax.skipped = true
|
||
|
|
setStage(nextStep.value)
|
||
|
|
}
|
||
|
|
|
||
|
|
async function handleWithdraw() {
|
||
|
|
if (isSubmitting.value) return
|
||
|
|
|
||
|
|
try {
|
||
|
|
isSubmitting.value = true
|
||
|
|
await submitWithdrawal()
|
||
|
|
setStage('completion')
|
||
|
|
} catch (error) {
|
||
|
|
console.error('Withdrawal failed:', error)
|
||
|
|
|
||
|
|
if ((error as any)?.data?.description?.toLower?.()?.includes('Tax form')) {
|
||
|
|
addNotification({
|
||
|
|
title: 'Please complete tax form',
|
||
|
|
text: 'You must complete a tax form to submit your withdrawal request.',
|
||
|
|
type: 'error',
|
||
|
|
})
|
||
|
|
} else {
|
||
|
|
addNotification({
|
||
|
|
title: 'Unable to withdraw',
|
||
|
|
text: 'We were unable to submit your withdrawal request, please check your details or contact support.',
|
||
|
|
type: 'error',
|
||
|
|
})
|
||
|
|
}
|
||
|
|
} finally {
|
||
|
|
isSubmitting.value = false
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
const shouldShowTitle = computed(() => {
|
||
|
|
return currentStage.value !== 'completion'
|
||
|
|
})
|
||
|
|
|
||
|
|
const isDetailsStage = computed(() => {
|
||
|
|
const detailsStages: WithdrawStage[] = [
|
||
|
|
'tremendous-details',
|
||
|
|
'muralpay-kyc',
|
||
|
|
'muralpay-details',
|
||
|
|
'paypal-details',
|
||
|
|
]
|
||
|
|
const current = currentStage.value
|
||
|
|
return current ? detailsStages.includes(current) : false
|
||
|
|
})
|
||
|
|
|
||
|
|
function showTaxFormModal(e?: MouseEvent) {
|
||
|
|
withdrawModal.value?.hide()
|
||
|
|
taxFormModal.value?.startTaxForm(e ?? new MouseEvent('click'))
|
||
|
|
}
|
||
|
|
|
||
|
|
function onTaxFormSuccess() {
|
||
|
|
emit('refresh-data')
|
||
|
|
nextTick(() => {
|
||
|
|
show('method-selection')
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
function onTaxFormCancelled() {
|
||
|
|
show('tax-form')
|
||
|
|
}
|
||
|
|
|
||
|
|
function onModalHide() {
|
||
|
|
resetData()
|
||
|
|
emit('hide')
|
||
|
|
}
|
||
|
|
|
||
|
|
function goToBreadcrumbStage(stage: WithdrawStage) {
|
||
|
|
setStage(stage, true)
|
||
|
|
}
|
||
|
|
|
||
|
|
function handleClose() {
|
||
|
|
withdrawModal.value?.hide()
|
||
|
|
emit('refresh-data')
|
||
|
|
}
|
||
|
|
|
||
|
|
function handleViewTransactions() {
|
||
|
|
withdrawModal.value?.hide()
|
||
|
|
navigateTo('/dashboard/revenue/transfers')
|
||
|
|
}
|
||
|
|
|
||
|
|
const messages = defineMessages({
|
||
|
|
taxFormStage: {
|
||
|
|
id: 'dashboard.creator-withdraw-modal.stage.tax-form',
|
||
|
|
defaultMessage: 'Tax form',
|
||
|
|
},
|
||
|
|
methodSelectionStage: {
|
||
|
|
id: 'dashboard.creator-withdraw-modal.stage.method-selection',
|
||
|
|
defaultMessage: 'Method',
|
||
|
|
},
|
||
|
|
tremendousDetailsStage: {
|
||
|
|
id: 'dashboard.creator-withdraw-modal.stage.tremendous-details',
|
||
|
|
defaultMessage: 'Details',
|
||
|
|
},
|
||
|
|
muralpayKycStage: {
|
||
|
|
id: 'dashboard.creator-withdraw-modal.stage.muralpay-kyc',
|
||
|
|
defaultMessage: 'Verification',
|
||
|
|
},
|
||
|
|
muralpayDetailsStage: {
|
||
|
|
id: 'dashboard.creator-withdraw-modal.stage.muralpay-details',
|
||
|
|
defaultMessage: 'Account Details',
|
||
|
|
},
|
||
|
|
completionStage: {
|
||
|
|
id: 'dashboard.creator-withdraw-modal.stage.completion',
|
||
|
|
defaultMessage: 'Complete',
|
||
|
|
},
|
||
|
|
detailsLabel: {
|
||
|
|
id: 'dashboard.creator-withdraw-modal.details-label',
|
||
|
|
defaultMessage: 'Details',
|
||
|
|
},
|
||
|
|
completeTaxForm: {
|
||
|
|
id: 'dashboard.creator-withdraw-modal.complete-tax-form',
|
||
|
|
defaultMessage: 'Complete tax form',
|
||
|
|
},
|
||
|
|
continueWithLimit: {
|
||
|
|
id: 'dashboard.creator-withdraw-modal.continue-with-limit',
|
||
|
|
defaultMessage: 'Continue with limit',
|
||
|
|
},
|
||
|
|
withdrawButton: {
|
||
|
|
id: 'dashboard.creator-withdraw-modal.withdraw-button',
|
||
|
|
defaultMessage: 'Withdraw',
|
||
|
|
},
|
||
|
|
closeButton: {
|
||
|
|
id: 'dashboard.withdraw.completion.close-button',
|
||
|
|
defaultMessage: 'Close',
|
||
|
|
},
|
||
|
|
transactionsButton: {
|
||
|
|
id: 'dashboard.withdraw.completion.transactions-button',
|
||
|
|
defaultMessage: 'Transactions',
|
||
|
|
},
|
||
|
|
})
|
||
|
|
</script>
|