Files
AstralRinth/apps/frontend/src/components/ui/dashboard/CreatorWithdrawModal.vue
Calum H. 3765a6ded8 feat: creator revenue page overhaul (#4204)
* feat: start on tax compliance

* feat: avarala1099 composable

* fix: shouldShow should be managed on the page itself

* refactor: move show logic to revenue page

* feat: security practices rather than info

* feat: withdraw page lock

* fix: empty modal bug & lint issues

* feat: hide behind feature flag

* Use standard admonition components, make casing consistent

* modal title

* lint

* feat: withdrawal check

* feat: tax cap on withdrawals warning

* feat: start on revenue page overhaul

* feat: segment generation for bar

* feat: tooltips and links

* fix: tooltip border

* feat: finish initial layout, start on withdraw modal

* feat: start on withdrawal limit stage

* feat: shade support for primary colors

* feat: start on withdraw details stage

* fix: convert swatches to hex

* feat: payout method/region dropdown temporarily using multiselect

* feat: fix modal open issues and use teleport dropdowns

* feat: hide transactions section if there are no transactions

* refactor: NavStack surfaces

* feat: new dropdown component

* feat: remove teleport dropdown modal in favour of new combobox component

* fix: lint

* refactor: dashboard sidebar layout

* feat: cleanup

* fix: niche bugs

* fix: ComboBox styling

* feat: first part of qa

* feat: animate flash rather than tooltip

* fix: lint

* feat: qa border gradient

* fix: seg hover flashes

* feat: i18n

* feat: i18n and final QA

* fix: lint

* feat: QA

* fix: lint

* fix: merge conflicts

* fix: intl

* fix: blue hover

* fix: transfers page

* feat: surface variables & gradients

* feat: text vars

* fix: lint

* fix: intl

* feat: stages

* fix: lint

* feat: region selection

* feat: method selection btns

* fix: flex col on transactions

* feat: hook up method selection to ctx

* feat: muralpay kyc stage info

* wip: muralpay integration

* Basic Mural Pay API bindings

* Fix clippy

* use dotenvy in muralpay example

* Refactor payout creation code

* wip: muralpay payout requests

* Mural Pay payouts work

* Fix clippy

* feat: progress

* fix: broken tax form stage logic

* polish: tax form stage and method selection stage layout

* add mural pay fees API

* Work on payout fee API

* Fees API for more payment methods

* Fix CI

* polish: muralpay qa

* refactor: clean up combobox component

* polish: change from critical -> warning admonition in MuralpayDetailsStage

* Temporarily disable Venmo and PayPal methods from frontend

* polish: clean up transaction component & page

* polish: navbar qa, text color-contrast in chips type buttonstyled, mb on rev/index.vue page

* fix: incorrectly using available balance as tax form withdraw limit after tax forms submitted

* wip: counterparties

* Start on counterparties and payment methods API

* polish: combobox component

* polish: fix broken scroll logic using a composable & web:fix

* fix: lint

* polish: various QA fixes

* feat: hook up with backend (wip)

* feat: draft muralpay rails dynamic logic

* polish: modify rails to support backend changes

* Mural Pay multiple methods when fetching

* Don't send supported_countries to frontend

* Mural Pay multiple methods when fetching

* Don't send supported_countries to frontend

* feat: fees & methods endpoint hookup

* chore: remove duplicates fix

* polish: qa changes + figma match

* Add countries to muralpay fiat methods

* Compile fix

* Add exchange rate info to fees endpoint

* Add fees to premium Tremendous options

* polish: i18n and better document type dropdown -> id input labels

* feat: tremendous

* fix: lint & i18n

* feat: reintroduce tin mismatch logic to index.vue

* polish: qa

* fix: i18n

* feat: remove teleport dropdown menu - combobox should be used

* fix: lint

* fix: jsdoc

* feat: checkbox for reward program terms

* Add delivery email field to Tremendous payouts

* Add Tremendous product category to payout methods

* Add bank details API to muralpay

* Fix CI

* Fix CI

* polish: qa changes

* feat: i18n pass

* feat: deduplicate methods endpoint & fix i18n issues

* chore: deduplicate i18n strings into common-messages.ts

* fix: lint

* fix: i18n

* feat: estimates

* polish: more QA

* Remove prepaid visa, compute fees properly for Tremendous methods

* Add more details to Tremendous errors

* feat: withdraw endpoint impl & internals refactor

* Add more details to Tremendous errors

* feat: completion stage

* Add fees to Mural

* feat: transactions page match figma

* fix: i18n

* polish: QA changes

* polish: qa

* Payout history route and bank details

* polish: autofill and requirements checks

* fix: i18n + lint

* fix: fiat rail fees

* polish: move scroll fade stuff into NewModal rather than just CreatorWithdrawModal

* feat: simplify action btn logic & tax form error

* fix: tax -> Tax form

* Re-add legacy PayPal/Venmo options for US

* feat: mobile responsiveness fixes for modal

* fix: responsiveness issues

* feat: navstack responsiveness

* fix: responsiveness

* move the mural bank details route

* fix: generated state cleanup & bank details input

* fix: lint & i18n

* Add utoipa support to payout endpoints

* address some PR comments

* polish: qa

* add CORS to new utoipa routes

* feat: legacy paypal/venmo stage

* polish: reset amount on back qa

* revert: navstack mr changes

* polish: loading indicator on method selection stage

* fix: paypal modal doesnt reopen after auth

* fix: lint & i18n

* fix: paypal flow

* polish: qa changes

* fix: gitignore

* polish: qa fixes

* fix: payouts_available in payouts.rs

* fix: bug when limit is zero

* polish: qa changes

* fix: qa stuff & muralpay sub-division fix

* Immediately approve mural payouts

* Add currency support to Tremendous payouts

* Currency forex

* add forex to tremendous fee request

* polish: qa & currency support for paypal tremendous

* polish: fx qa

* feat: demo mode flag

* fix: i18n & padding issues

* polish: qa changes

* fix: ml

* Add Mural balance to bank balance info

* polish: show warning for paypal international USD withdrawals + more currencies

* Add more Tremendous currencies support

* fix: colors on balance bars

* fix: empty states

* fix: pl-8 mobile issue

* fix: hide see all

* Transaction payouts available use the correct date

* Address my own review comment

* Address PR comments

* Change Mural withdrawal limit to 3k

* fix: empty state + paypal warning

* maybe fix tremendous gift cards

* Change how Mural minimum withdrawals are calculated

* Tweak min/max withdrawal values

* fix: segment brightness

* fix: min & max for muralpay & legacy paypal

* Fix some icon issues

* more issues

* fix user menu

* fix: remove + network

---------

Signed-off-by: Calum H. <contact@cal.engineer>
Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
Co-authored-by: aecsocket <aecsocket@tutanota.com>
Co-authored-by: Alejandro González <me@alegon.dev>
2025-11-03 15:15:25 -08:00

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>