You've already forked AstralRinth
forked from didirus/AstralRinth
feat: improve error handling for withdraw modal (#5054)
* feat: improve error handling for withdraw modal * fix: add headers to error info * prepr --------- Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
This commit is contained in:
@@ -128,6 +128,7 @@ import { computed, nextTick, onMounted, ref, useTemplateRef, watch } from 'vue'
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
createWithdrawContext,
|
createWithdrawContext,
|
||||||
|
type PaymentProvider,
|
||||||
type PayoutMethod,
|
type PayoutMethod,
|
||||||
provideWithdrawContext,
|
provideWithdrawContext,
|
||||||
TAX_THRESHOLD_ACTUAL,
|
TAX_THRESHOLD_ACTUAL,
|
||||||
@@ -332,51 +333,76 @@ function continueWithLimit() {
|
|||||||
setStage(nextStep.value)
|
setStage(nextStep.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: God we need better errors from the backend (e.g error ids), this shit is insane
|
function buildSupportData(error: any): Record<string, unknown> {
|
||||||
function getWithdrawalError(error: any): { title: string; text: string } {
|
// Extract response headers, excluding sensitive ones
|
||||||
const description = error?.data?.description?.toLowerCase() || ''
|
const responseHeaders: Record<string, string> = {}
|
||||||
|
if (error?.response?.headers) {
|
||||||
|
const headers = error.response.headers
|
||||||
|
const entries =
|
||||||
|
typeof headers.entries === 'function' ? [...headers.entries()] : Object.entries(headers)
|
||||||
|
for (const [key, value] of entries) {
|
||||||
|
const lowerKey = key.toLowerCase()
|
||||||
|
// Exclude sensitive headers
|
||||||
|
if (!['authorization', 'cookie', 'set-cookie'].includes(lowerKey)) {
|
||||||
|
responseHeaders[key] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
provider: withdrawData.value.selection.provider,
|
||||||
|
method: withdrawData.value.selection.method,
|
||||||
|
methodId: withdrawData.value.selection.methodId,
|
||||||
|
country: withdrawData.value.selection.country?.id,
|
||||||
|
amount: withdrawData.value.calculation?.amount,
|
||||||
|
fee: withdrawData.value.calculation?.fee,
|
||||||
|
request: {
|
||||||
|
url: 'POST /api/v3/payout',
|
||||||
|
},
|
||||||
|
response: {
|
||||||
|
status: error?.response?.status ?? error?.statusCode,
|
||||||
|
statusText: error?.response?.statusText,
|
||||||
|
headers: responseHeaders,
|
||||||
|
body: error?.data,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatBilingualText(
|
||||||
|
messageDescriptor: { id: string; defaultMessage: string },
|
||||||
|
values?: Record<string, string>,
|
||||||
|
): string {
|
||||||
|
const localized = formatMessage(messageDescriptor, values)
|
||||||
|
// Interpolate values into the English default message
|
||||||
|
let english = messageDescriptor.defaultMessage
|
||||||
|
if (values) {
|
||||||
|
for (const [key, value] of Object.entries(values)) {
|
||||||
|
english = english.replace(`{${key}}`, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (localized === english) {
|
||||||
|
return localized
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${localized}\n${english}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function getWithdrawalError(
|
||||||
|
error: any,
|
||||||
|
provider: PaymentProvider | null,
|
||||||
|
): { title: string; text: string; supportData: Record<string, unknown> } {
|
||||||
|
const description = error?.data?.description?.toLowerCase() || ''
|
||||||
|
const supportData = buildSupportData(error)
|
||||||
|
|
||||||
|
// === Common patterns (all providers) ===
|
||||||
// Tax form error
|
// Tax form error
|
||||||
if (description.includes('tax form')) {
|
if (description.includes('tax form')) {
|
||||||
return {
|
return {
|
||||||
title: formatMessage(messages.errorTaxFormTitle),
|
title: formatMessage(messages.errorTaxFormTitle),
|
||||||
text: formatMessage(messages.errorTaxFormText),
|
text: formatBilingualText(messages.errorTaxFormText),
|
||||||
}
|
supportData,
|
||||||
}
|
|
||||||
|
|
||||||
// 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),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -388,14 +414,106 @@ function getWithdrawalError(error: any): { title: string; text: string } {
|
|||||||
) {
|
) {
|
||||||
return {
|
return {
|
||||||
title: formatMessage(messages.errorMinimumNotMetTitle),
|
title: formatMessage(messages.errorMinimumNotMetTitle),
|
||||||
text: formatMessage(messages.errorMinimumNotMetText),
|
text: formatBilingualText(messages.errorMinimumNotMetText),
|
||||||
|
supportData,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insufficient balance
|
||||||
|
if (description.includes('enough funds') || description.includes('insufficient')) {
|
||||||
|
return {
|
||||||
|
title: formatMessage(messages.errorInsufficientBalanceTitle),
|
||||||
|
text: formatBilingualText(messages.errorInsufficientBalanceText),
|
||||||
|
supportData,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Email verification required
|
||||||
|
if (description.includes('verify your email') || description.includes('email_verified')) {
|
||||||
|
return {
|
||||||
|
title: formatMessage(messages.errorEmailVerificationTitle),
|
||||||
|
text: formatBilingualText(messages.errorEmailVerificationText),
|
||||||
|
supportData,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// === MuralPay-only patterns ===
|
||||||
|
if (provider === 'muralpay') {
|
||||||
|
// 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: formatBilingualText(messages.errorInvalidWalletText),
|
||||||
|
supportData,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Invalid bank details
|
||||||
|
if (
|
||||||
|
(description.includes('bank') || description.includes('account')) &&
|
||||||
|
(description.includes('invalid') || description.includes('failed'))
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
title: formatMessage(messages.errorInvalidBankTitle),
|
||||||
|
text: formatBilingualText(messages.errorInvalidBankText),
|
||||||
|
supportData,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Invalid/fraudulent address (physical address for KYC)
|
||||||
|
if (
|
||||||
|
description.includes('address') &&
|
||||||
|
(description.includes('invalid') ||
|
||||||
|
description.includes('verification') ||
|
||||||
|
description.includes('fraudulent'))
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
title: formatMessage(messages.errorInvalidAddressTitle),
|
||||||
|
text: formatBilingualText(messages.errorInvalidAddressText),
|
||||||
|
supportData,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// === PayPal/Venmo-only patterns ===
|
||||||
|
if (provider === 'paypal' || provider === 'venmo') {
|
||||||
|
// Account not linked
|
||||||
|
if (
|
||||||
|
description.includes('not linked') ||
|
||||||
|
description.includes('link') ||
|
||||||
|
description.includes('paypal account') ||
|
||||||
|
description.includes('venmo')
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
title: formatMessage(messages.errorAccountNotLinkedTitle),
|
||||||
|
text: formatBilingualText(messages.errorAccountNotLinkedText),
|
||||||
|
supportData,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Country mismatch for PayPal
|
||||||
|
if (
|
||||||
|
provider === 'paypal' &&
|
||||||
|
(description.includes('us paypal') || description.includes('international paypal'))
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
title: formatMessage(messages.errorPaypalCountryMismatchTitle),
|
||||||
|
text: formatBilingualText(messages.errorPaypalCountryMismatchText),
|
||||||
|
supportData,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generic fallback
|
// Generic fallback
|
||||||
|
const errorDescription = error?.data?.description || ''
|
||||||
return {
|
return {
|
||||||
title: formatMessage(messages.errorGenericTitle),
|
title: formatMessage(messages.errorGenericTitle),
|
||||||
text: formatMessage(messages.errorGenericText),
|
text: formatBilingualText(messages.errorGenericText, { error: errorDescription }),
|
||||||
|
supportData,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -409,11 +527,15 @@ async function handleWithdraw() {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Withdrawal failed:', error)
|
console.error('Withdrawal failed:', error)
|
||||||
|
|
||||||
const { title, text } = getWithdrawalError(error)
|
const { title, text, supportData } = getWithdrawalError(
|
||||||
|
error,
|
||||||
|
withdrawData.value.selection.provider,
|
||||||
|
)
|
||||||
addNotification({
|
addNotification({
|
||||||
title,
|
title,
|
||||||
text,
|
text,
|
||||||
type: 'error',
|
type: 'error',
|
||||||
|
supportData,
|
||||||
})
|
})
|
||||||
} finally {
|
} finally {
|
||||||
isSubmitting.value = false
|
isSubmitting.value = false
|
||||||
@@ -570,7 +692,40 @@ const messages = defineMessages({
|
|||||||
errorGenericText: {
|
errorGenericText: {
|
||||||
id: 'dashboard.withdraw.error.generic.text',
|
id: 'dashboard.withdraw.error.generic.text',
|
||||||
defaultMessage:
|
defaultMessage:
|
||||||
'We were unable to submit your withdrawal request, please check your details or contact support.',
|
'We were unable to submit your withdrawal request, please check your details or contact support.\n{error}',
|
||||||
|
},
|
||||||
|
errorInsufficientBalanceTitle: {
|
||||||
|
id: 'dashboard.withdraw.error.insufficient-balance.title',
|
||||||
|
defaultMessage: 'Insufficient balance',
|
||||||
|
},
|
||||||
|
errorInsufficientBalanceText: {
|
||||||
|
id: 'dashboard.withdraw.error.insufficient-balance.text',
|
||||||
|
defaultMessage: 'You do not have enough funds to make this withdrawal.',
|
||||||
|
},
|
||||||
|
errorEmailVerificationTitle: {
|
||||||
|
id: 'dashboard.withdraw.error.email-verification.title',
|
||||||
|
defaultMessage: 'Email verification required',
|
||||||
|
},
|
||||||
|
errorEmailVerificationText: {
|
||||||
|
id: 'dashboard.withdraw.error.email-verification.text',
|
||||||
|
defaultMessage: 'You must verify your email address before withdrawing funds.',
|
||||||
|
},
|
||||||
|
errorAccountNotLinkedTitle: {
|
||||||
|
id: 'dashboard.withdraw.error.account-not-linked.title',
|
||||||
|
defaultMessage: 'Account not linked',
|
||||||
|
},
|
||||||
|
errorAccountNotLinkedText: {
|
||||||
|
id: 'dashboard.withdraw.error.account-not-linked.text',
|
||||||
|
defaultMessage: 'Please link your payment account before withdrawing.',
|
||||||
|
},
|
||||||
|
errorPaypalCountryMismatchTitle: {
|
||||||
|
id: 'dashboard.withdraw.error.paypal-country-mismatch.title',
|
||||||
|
defaultMessage: 'PayPal region mismatch',
|
||||||
|
},
|
||||||
|
errorPaypalCountryMismatchText: {
|
||||||
|
id: 'dashboard.withdraw.error.paypal-country-mismatch.text',
|
||||||
|
defaultMessage:
|
||||||
|
'Please use the correct PayPal transfer option for your region (US or International).',
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -980,12 +980,30 @@
|
|||||||
"dashboard.withdraw.completion.wallet": {
|
"dashboard.withdraw.completion.wallet": {
|
||||||
"message": "Wallet"
|
"message": "Wallet"
|
||||||
},
|
},
|
||||||
|
"dashboard.withdraw.error.account-not-linked.text": {
|
||||||
|
"message": "Please link your payment account before withdrawing."
|
||||||
|
},
|
||||||
|
"dashboard.withdraw.error.account-not-linked.title": {
|
||||||
|
"message": "Account not linked"
|
||||||
|
},
|
||||||
|
"dashboard.withdraw.error.email-verification.text": {
|
||||||
|
"message": "You must verify your email address before withdrawing funds."
|
||||||
|
},
|
||||||
|
"dashboard.withdraw.error.email-verification.title": {
|
||||||
|
"message": "Email verification required"
|
||||||
|
},
|
||||||
"dashboard.withdraw.error.generic.text": {
|
"dashboard.withdraw.error.generic.text": {
|
||||||
"message": "We were unable to submit your withdrawal request, please check your details or contact support."
|
"message": "We were unable to submit your withdrawal request, please check your details or contact support.\n{error}"
|
||||||
},
|
},
|
||||||
"dashboard.withdraw.error.generic.title": {
|
"dashboard.withdraw.error.generic.title": {
|
||||||
"message": "Unable to withdraw"
|
"message": "Unable to withdraw"
|
||||||
},
|
},
|
||||||
|
"dashboard.withdraw.error.insufficient-balance.text": {
|
||||||
|
"message": "You do not have enough funds to make this withdrawal."
|
||||||
|
},
|
||||||
|
"dashboard.withdraw.error.insufficient-balance.title": {
|
||||||
|
"message": "Insufficient balance"
|
||||||
|
},
|
||||||
"dashboard.withdraw.error.invalid-address.text": {
|
"dashboard.withdraw.error.invalid-address.text": {
|
||||||
"message": "The address you provided could not be verified. Please check your address details."
|
"message": "The address you provided could not be verified. Please check your address details."
|
||||||
},
|
},
|
||||||
@@ -1010,6 +1028,12 @@
|
|||||||
"dashboard.withdraw.error.minimum-not-met.title": {
|
"dashboard.withdraw.error.minimum-not-met.title": {
|
||||||
"message": "Amount too low"
|
"message": "Amount too low"
|
||||||
},
|
},
|
||||||
|
"dashboard.withdraw.error.paypal-country-mismatch.text": {
|
||||||
|
"message": "Please use the correct PayPal transfer option for your region (US or International)."
|
||||||
|
},
|
||||||
|
"dashboard.withdraw.error.paypal-country-mismatch.title": {
|
||||||
|
"message": "PayPal region mismatch"
|
||||||
|
},
|
||||||
"dashboard.withdraw.error.tax-form.text": {
|
"dashboard.withdraw.error.tax-form.text": {
|
||||||
"message": "You must complete a tax form to submit your withdrawal request."
|
"message": "You must complete a tax form to submit your withdrawal request."
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -49,8 +49,13 @@
|
|||||||
x{{ item.count }}
|
x{{ item.count }}
|
||||||
</div>
|
</div>
|
||||||
<ButtonStyled circular size="small">
|
<ButtonStyled circular size="small">
|
||||||
<button v-tooltip="'Copy to clipboard'" @click="copyToClipboard(item)">
|
<button
|
||||||
<CheckIcon v-if="copied[createNotifText(item)]" />
|
v-tooltip="
|
||||||
|
item.supportData ? 'Copy error details for support' : 'Copy to clipboard'
|
||||||
|
"
|
||||||
|
@click="copyToClipboard(item)"
|
||||||
|
>
|
||||||
|
<CheckIcon v-if="copied[getCopyKey(item)]" />
|
||||||
<CopyIcon v-else />
|
<CopyIcon v-else />
|
||||||
</button>
|
</button>
|
||||||
</ButtonStyled>
|
</ButtonStyled>
|
||||||
@@ -106,18 +111,26 @@ function createNotifText(notif: WebNotification): string {
|
|||||||
return [notif.title, notif.text, notif.errorCode].filter(Boolean).join('\n')
|
return [notif.title, notif.text, notif.errorCode].filter(Boolean).join('\n')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getCopyKey(notif: WebNotification): string {
|
||||||
|
return notif.supportData ? `support-${notif.id}` : createNotifText(notif)
|
||||||
|
}
|
||||||
|
|
||||||
function checkIntercomPresence(): void {
|
function checkIntercomPresence(): void {
|
||||||
isIntercomPresent.value = !!document.querySelector('.intercom-lightweight-app')
|
isIntercomPresent.value = !!document.querySelector('.intercom-lightweight-app')
|
||||||
}
|
}
|
||||||
|
|
||||||
function copyToClipboard(notif: WebNotification): void {
|
function copyToClipboard(notif: WebNotification): void {
|
||||||
const text = createNotifText(notif)
|
// If supportData is present, copy the full JSON for support; otherwise copy plain text
|
||||||
|
const text = notif.supportData
|
||||||
|
? JSON.stringify(notif.supportData, null, 2)
|
||||||
|
: createNotifText(notif)
|
||||||
|
|
||||||
copied.value[text] = true
|
const key = getCopyKey(notif)
|
||||||
|
copied.value[key] = true
|
||||||
navigator.clipboard.writeText(text)
|
navigator.clipboard.writeText(text)
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
const { [text]: _, ...rest } = copied.value
|
const { [key]: _, ...rest } = copied.value
|
||||||
copied.value = rest
|
copied.value = rest
|
||||||
}, 2000)
|
}, 2000)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export interface WebNotification {
|
|||||||
errorCode?: string
|
errorCode?: string
|
||||||
count?: number
|
count?: number
|
||||||
timer?: NodeJS.Timeout
|
timer?: NodeJS.Timeout
|
||||||
|
supportData?: Record<string, unknown>
|
||||||
}
|
}
|
||||||
|
|
||||||
export type NotificationPanelLocation = 'left' | 'right'
|
export type NotificationPanelLocation = 'left' | 'right'
|
||||||
|
|||||||
Reference in New Issue
Block a user