polish: withdraw flow fixes (#4713)

* fix: negative value stuff

* fix: mobile responsiveness for modal min-w

* feat: better error handling on withdraw

* fix: empty state positioning + svg sizing

* fix: title case -> sentence case

* fix: re-add virtual visa under gift cards

* fix: hide <1% segments
This commit is contained in:
Calum H.
2025-11-04 21:29:47 +00:00
committed by GitHub
parent 6e47de06bb
commit f054f39c5d
9 changed files with 285 additions and 121 deletions

View File

@@ -38,7 +38,7 @@
</template>
</div>
</template>
<div class="w-full max-w-[496px] lg:min-w-[496px]">
<div class="mx-auto w-full max-w-[496px] sm:mx-0 sm:min-w-[496px]">
<TaxFormStage
v-if="currentStage === 'tax-form'"
:balance="balance"
@@ -326,6 +326,73 @@ function continueWithLimit() {
setStage(nextStep.value)
}
// TODO: God we need better errors from the backend (e.g error ids), this shit is insane
function getWithdrawalError(error: any): { title: string; text: string } {
const description = error?.data?.description?.toLowerCase() || ''
// Tax form error
if (description.includes('tax form')) {
return {
title: formatMessage(messages.errorTaxFormTitle),
text: formatMessage(messages.errorTaxFormText),
}
}
// Invalid crypto wallet address
if (
(description.includes('wallet') && description.includes('invalid')) ||
description.includes('wallet_address') ||
(description.includes('blockchain') && description.includes('invalid'))
) {
return {
title: formatMessage(messages.errorInvalidWalletTitle),
text: formatMessage(messages.errorInvalidWalletText),
}
}
// Invalid bank details
if (
(description.includes('bank') || description.includes('account')) &&
(description.includes('invalid') || description.includes('failed'))
) {
return {
title: formatMessage(messages.errorInvalidBankTitle),
text: formatMessage(messages.errorInvalidBankText),
}
}
// Invalid/fraudulent address
if (
description.includes('address') &&
(description.includes('invalid') ||
description.includes('verification') ||
description.includes('fraudulent'))
) {
return {
title: formatMessage(messages.errorInvalidAddressTitle),
text: formatMessage(messages.errorInvalidAddressText),
}
}
// Minimum amount not met
if (
description.includes('payoutminimumnotmeterror') ||
description.includes('minimum') ||
(description.includes('amount') && description.includes('less'))
) {
return {
title: formatMessage(messages.errorMinimumNotMetTitle),
text: formatMessage(messages.errorMinimumNotMetText),
}
}
// Generic fallback
return {
title: formatMessage(messages.errorGenericTitle),
text: formatMessage(messages.errorGenericText),
}
}
async function handleWithdraw() {
if (isSubmitting.value) return
@@ -336,19 +403,12 @@ async function handleWithdraw() {
} 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',
})
}
const { title, text } = getWithdrawalError(error)
addNotification({
title,
text,
type: 'error',
})
} finally {
isSubmitting.value = false
}
@@ -453,5 +513,58 @@ const messages = defineMessages({
id: 'dashboard.withdraw.completion.transactions-button',
defaultMessage: 'Transactions',
},
errorTaxFormTitle: {
id: 'dashboard.withdraw.error.tax-form.title',
defaultMessage: 'Please complete tax form',
},
errorTaxFormText: {
id: 'dashboard.withdraw.error.tax-form.text',
defaultMessage: 'You must complete a tax form to submit your withdrawal request.',
},
errorInvalidWalletTitle: {
id: 'dashboard.withdraw.error.invalid-wallet.title',
defaultMessage: 'Invalid wallet address',
},
errorInvalidWalletText: {
id: 'dashboard.withdraw.error.invalid-wallet.text',
defaultMessage:
'The crypto wallet address you provided is invalid. Please double-check and try again.',
},
errorInvalidBankTitle: {
id: 'dashboard.withdraw.error.invalid-bank.title',
defaultMessage: 'Invalid bank details',
},
errorInvalidBankText: {
id: 'dashboard.withdraw.error.invalid-bank.text',
defaultMessage:
'The bank account details you provided are invalid. Please verify your information.',
},
errorInvalidAddressTitle: {
id: 'dashboard.withdraw.error.invalid-address.title',
defaultMessage: 'Address verification failed',
},
errorInvalidAddressText: {
id: 'dashboard.withdraw.error.invalid-address.text',
defaultMessage:
'The address you provided could not be verified. Please check your address details.',
},
errorMinimumNotMetTitle: {
id: 'dashboard.withdraw.error.minimum-not-met.title',
defaultMessage: 'Amount too low',
},
errorMinimumNotMetText: {
id: 'dashboard.withdraw.error.minimum-not-met.text',
defaultMessage:
"The withdrawal amount (after fees) doesn't meet the minimum requirement. Please increase your withdrawal amount.",
},
errorGenericTitle: {
id: 'dashboard.withdraw.error.generic.title',
defaultMessage: 'Unable to withdraw',
},
errorGenericText: {
id: 'dashboard.withdraw.error.generic.text',
defaultMessage:
'We were unable to submit your withdrawal request, please check your details or contact support.',
},
})
</script>

View File

@@ -8,7 +8,8 @@
type="number"
step="0.01"
:min="minAmount"
:max="maxAmount"
:max="safeMaxAmount"
:disabled="isDisabled"
:placeholder="formatMessage(formFieldPlaceholders.amountPlaceholder)"
class="w-full rounded-[14px] bg-surface-4 py-2.5 pl-4 pr-4 text-contrast placeholder:text-secondary"
@input="handleInput"
@@ -26,13 +27,13 @@
</template>
</Combobox>
<ButtonStyled>
<button class="px-4 py-2" @click="setMaxAmount">
<button class="px-4 py-2" :disabled="isDisabled" @click="setMaxAmount">
{{ formatMessage(commonMessages.maxButton) }}
</button>
</ButtonStyled>
</div>
<div>
<span class="my-1 mt-0 text-secondary">{{ formatMoney(maxAmount) }} available.</span>
<span class="my-1 mt-0 text-secondary">{{ formatMoney(safeMaxAmount) }} available.</span>
<Transition name="fade">
<span v-if="isBelowMinimum" class="text-red">
Amount must be at least {{ formatMoney(minAmount) }}.
@@ -40,7 +41,7 @@
</Transition>
<Transition name="fade">
<span v-if="isAboveMaximum" class="text-red">
Amount cannot exceed {{ formatMoney(maxAmount) }}.
Amount cannot exceed {{ formatMoney(safeMaxAmount) }}.
</span>
</Transition>
</div>
@@ -77,6 +78,14 @@ const emit = defineEmits<{
const { formatMessage } = useVIntl()
const amountInput = ref<HTMLInputElement | null>(null)
const safeMaxAmount = computed(() => {
return Math.max(0, props.maxAmount)
})
const isDisabled = computed(() => {
return safeMaxAmount.value < 0.01
})
const isBelowMinimum = computed(() => {
return (
props.modelValue !== undefined && props.modelValue > 0 && props.modelValue < props.minAmount
@@ -84,11 +93,11 @@ const isBelowMinimum = computed(() => {
})
const isAboveMaximum = computed(() => {
return props.modelValue !== undefined && props.modelValue > props.maxAmount
return props.modelValue !== undefined && props.modelValue > safeMaxAmount.value
})
async function setMaxAmount() {
const maxValue = props.maxAmount
const maxValue = safeMaxAmount.value
emit('update:modelValue', maxValue)
await nextTick()
@@ -119,11 +128,11 @@ watch(
() => props.modelValue,
async (newAmount) => {
if (newAmount !== undefined && newAmount !== null) {
if (newAmount > props.maxAmount) {
emit('update:modelValue', props.maxAmount)
if (newAmount > safeMaxAmount.value) {
emit('update:modelValue', safeMaxAmount.value)
await nextTick()
if (amountInput.value) {
amountInput.value.value = props.maxAmount.toFixed(2)
amountInput.value.value = safeMaxAmount.value.toFixed(2)
}
} else if (newAmount < 0) {
emit('update:modelValue', 0)

View File

@@ -325,7 +325,7 @@ const dynamicDocumentNumberField = computed(() => {
return {
name: 'documentNumber',
type: 'text' as const,
label: labelMap[documentType] || 'Document Number',
label: labelMap[documentType] || 'Document number',
placeholder: placeholderMap[documentType] || 'Enter document number',
required: true,
}
@@ -474,23 +474,23 @@ const messages = defineMessages({
},
documentNumberNationalId: {
id: 'dashboard.creator-withdraw-modal.muralpay-details.document-number-national-id',
defaultMessage: 'National ID Number',
defaultMessage: 'National ID number',
},
documentNumberPassport: {
id: 'dashboard.creator-withdraw-modal.muralpay-details.document-number-passport',
defaultMessage: 'Passport Number',
defaultMessage: 'Passport number',
},
documentNumberResidentId: {
id: 'dashboard.creator-withdraw-modal.muralpay-details.document-number-resident-id',
defaultMessage: 'Resident ID Number',
defaultMessage: 'Resident ID number',
},
documentNumberRuc: {
id: 'dashboard.creator-withdraw-modal.muralpay-details.document-number-ruc',
defaultMessage: 'RUC Number',
defaultMessage: 'RUC number',
},
documentNumberTaxId: {
id: 'dashboard.creator-withdraw-modal.muralpay-details.document-number-tax-id',
defaultMessage: 'Tax ID Number',
defaultMessage: 'Tax ID number',
},
documentNumberNationalIdPlaceholder: {
id: 'dashboard.creator-withdraw-modal.muralpay-details.document-number-national-id-placeholder',

View File

@@ -529,7 +529,11 @@ onMounted(async () => {
rewardOptions.value = methods
.filter((m) => m.type === 'tremendous')
.filter((m) => m.category === selectedMethod)
.filter(
(m) =>
m.category === selectedMethod ||
(selectedMethod === 'merchant_card' && m.category === 'visa_card'),
)
.map((m) => ({
value: m.id,
label: m.name,